Production-Level Patterns for React Hooks 🎣🦈

Written by
FullStack Talent

In this post, I’ll dive into patterns for architecting production-level React hook apps.

Separation of Concerns

Unlike class components which organize all lifecycle methods into a single class, hooks distribute them into reusable functions that can be used across more than one component or file.

Initially, it is tempting to add all required hooks to the top of a giant function and return JSX at the bottom. Check out this example to-do app code below:


import React from 'react';
‍import { useState, useEffect, useCallback } from 'react';
‍import { useNavigation } from 'react-navigation-hooks';
‍import { useDispatch, useSelector } from 'react-redux';

‍const Todos = () => {
    const { setParams, getParam, navigate } = useNavigation();       const initSearch = getParam('initSearch', '');  
    const [searchText, setSearchText] = useState(initSearch);  
    const {    
        todos,    
        loading,  
    } = useSelector(({ todos: todosData }) => ({
        todos: todosData.todos,    
        loading: todosData.loading,  
    }));  
    const dispatch = useDispatch();      useEffect(() => {            dispatch(getTodos());  
    }, [dispatch]);    
    
    /* Functions for handling data and responding to events */      onItemPressed = (item) => navigate('TODO_DETAIL', { todo })   
        {...}    
‍
    return loading ? (    
        <loadingindicator>   </loadingindicator>
    ) : (    
        <view></view>
            
                value={searchText}              
                onChangeText={setSearchText}      
            />
            <list data="{todos}" onitempressed="{onItemPressed}/"></list>
      );}

This method works, but it is not very readable and creates unnecessary complexity.

To clean things up, refactor each hook that accesses data into a custom hook in a separate file.


import { useState, useEffect, useCallback } from 'react';
‍import { useNavigation } from 'react-navigation-hooks';
‍import { useDispatch, useSelector } from 'react-redux';

‍const useTodosHooks = () => {  
    const { setParams, getParam, navigate } = useNavigation();  
    const initSearch = getParam('initSearch', '');  
    const [searchText, setSearchText] = useState(initSearch);  
    const {    
        todos,    
        loading,  
    } = useSelector(({ todos: todosData }) => ({    
        todos: todosData.todos,    
        loading: todosData.loading,  }));       const dispatch = useDispatch();      useEffect(() => {            dispatch(getTodos());  
    }, [dispatch]);    
    
    return {    
        state:{ searchText, setSearchText },    
        navigate,    
        todos,    
    loading  
    }}

This provides a separation of the data and the view, but the component function is still very long, and contains a lot of business logic.

Additionally, testing with this structure is difficult. Without a class instance, state changes can only be executed by interacting with components of a view. There is no way to spy on the setters when using useState.

To address the issues above, separate the logic from the view.

import React from 'react';
import useTodosHooks from './hooks';
import TodosScreen from './components/TodosScreen';

const Todos = () => {  
    const { navigate, ...screenProps } = useTodosHooks();    
  
    screenProps.onItemPressed = (item) => navigate('TODO_DETAIL', { todo })  

    return&lt;todosscreen {...screenprops}="">&lt;/todosscreen>}

Logic


import React from 'react';
‍import useTodosHooks from './hooks';
‍import TodosScreen from './components/TodosScreen';

‍const Todos = () => {  
    const { navigate, ...screenProps } = useTodosHooks();    
  
    screenProps.onItemPressed = (item) => navigate('TODO_DETAIL', { todo })  

    return<todosscreen {...screenprops}=""></todosscreen>}

View


import React from 'react';
Import { View } from ‘react-native;
Import Searchfield from ‘./SearchField’;
Import List from ‘./List’;
Import LoadingIndicator from ‘./LoadingIndicator;

‍const TodoScreen = ({ loading, searchText, setSearchText, todos,  onItemPressed }) => {  
    return loading ? (    
        <loadingindicator>   </loadingindicator>
    ) : (    
        <view></view>
            <searchfield  value="{searchText}" onchangetext="{setSearchText}"></searchfield>
            <list data="{todos}" onitempressed="{onItemPressed}/"></list>
          
    );}

With the separation above, we have emulated the popular MVC architecture using hooks.

Model

This is the file where state and functional hooks are defined, as shown above.

View

This is the rendered JSX. The props of this functional component are only the necessary state and functions for displaying and interacting with the view.

Controller

This is the initial, giant function. It now de-structures only what it needs from the model, some of which may be passed down to the view. Notice that all state and setters are wrapped in a state object for ease of transport and again being able to destructure only what is necessary.

While there are now more files, each of these three functions has a specific job and references only the data that is necessary. Each file is more readable, and therefore easier to understand and review. The code is also much easier to cover with tests. The controller can be mounted, with mocks for any library hooks, and can cover most of the three files. When state needs to be altered or a setter needs to be spied on, the view component can be mounted with mocked props inputs.

Making it Modular

In a production-level application, the same data and logic is going to be used in several places. This provides an opportunity to eliminate repetition in the models.

A prime example is the react-redux useSelector hook. Many views will rely on the same data, which mean selectors and their getter actions wind up implemented in several models. A file can be made for a redux wrapper, and another can be made for a combination of selectors and dispatch hooks per data type.


import { getTodos } from '../actions/todos';

‍exportconst useGetTodos = () => {  
    const dispatch = useDispatch();  
‍
    const {    
        todos,    
        loading,  
   
    } = useSelector(({ todos: todosData }) => ({    
       todos: todosData.todos,    
       loading: todosData.loading,  
    }));  
    useEffect(() => {            dispatch(getTodos());  
    }, [dispatch]); 

    return {    
        todos,    
        todosLoading: loading,  
    };};

Understanding Hook Dependencies

Using hooks incorrectly can easily cause “unnecessary” re-renderings. Although this has become a common concern surrounding hooks, it can be mitigated by gaining a better understanding of how hooks activate based on their dependencies.

The key lies in the secondary argument in useEffect, useMemo, and useCallback. In the to-do example above, the useEffect hook depends on dispatch and is passed in as the second parameter. This means the function will not redefine dispatch on every render because the context value of dispatch does not change. Therefore, useEffect will not rerun on every render.

If the dependency wasn't present, the call to getTodos would occur on every render. This would cause an infinite render loop because the redux selector would get a new state every render and then dispatch would fire again causing another render. In the class lifecycle this would be akin to unconditionally dispatching on every componentDidUpdate or, more accurately, in the render function itself.

Recommended Reading

Forget about component lifecycles and start thinking in effects - Sebastian De Deyne

---

At FullStack Labs, we are consistently asked for ways to speed up time-to-market and improve project maintainability. We pride ourselves on our ability to push the capabilities of these cutting-edge libraries. Interested in learning more about speeding up development time on your next form project, or improving an existing codebase with forms? Contact us.