Production-Level Patterns for React Hooks 🎣🦈

Written by Adam Burdette

In my last post, An Introduction to React.js Hooks, I provided a general introduction to hooks with a real world example. 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 /> 
  ) : (
    <View>
        <Searchfield 
            value={searchText}
              onChangeText={setSearchText}
      />
      <List data={todos} onItemPressed={onItemPressed}/>
    </View>
  );
}
    
  

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.


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} />
}
    
  

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 /> 
  ) : (
    <View>
       <Searchfield  value={searchText} onChangeText={setSearchText} />
      <List data={todos} onItemPressed={onItemPressed}/>
    </View>
  );
}
    
  

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';

export const 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.

Let’s Talk!

We’d love to learn more about your project. Contact us below for a free consultation with our CEO.
Projects Start at $25,000.

FullStack Labs
This field is required
This field is required
Type of project
Reason for contact:
How did you hear about us? This field is required