Frontend Architecture in the hooks era

Hooks have released a few months ago and the community is still playing around with them and tries to find the best patterns. At this point it doesn’t seem to be one single best solution on how to completely replace state management libs, both in terms of code but also in terms of folder/file structure in the project, so here I am mostly document my journey into that process, and what works best for me.

pirate

It became clear by now that the new context API is the go-to solution when it comes to storing state that has to be “global”. Before deciding to create a context provider that will hold the state, you have to question if the state really needs to be global. In this post I will assume that it has to be global.

For the sake of simplicity and to explain my ideas, let’s imagine that we are asked to implement a form with 2 fields, name and surname.

It’s important to notice that I omit any sort of performance optimizations.

Solution 1

Let’s start initially create our context provider and hold state using the useState hook.

import React, { createContext } from 'react';


const FormContext = createContext([{}, () => null]);

function FormProvider(props) {
  const [formState, setFormState] = useState({
    name: "",
    surname: ""
  });

  return (
    <FormContext.Provider value={[formState, setName, setSurname]}>
      {props.children}
    </FormContext.Provider>
  );
}

export default { FormProvider, FormContext };

Assuming we have wrapped somewhere higher in the hierarchy the children with the FormProvider, we can use the context in our form like:

import React, { useContext } from 'providers/FormProvider';
import { FormContext } from 'providers/FormProvider';

function Form() {
  const [formState, setFormState] = useContext(FormContext);

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={e => 
            setFormState({ 
              ...formState,
              name: e.target.value
            })}
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={e =>
            setFormState({ 
              ...formState,
              surname: e.target.value
            })
          }
        />
      </div>
    </form>
  );
}
  • One thing that I immediately do not like in this approach is that wherever I want to use this context I have to import both the FormContext and the useContext.
  • It also makes mocking the context on the tests more complicated than needed.

Solution 2

Refactoring it a bit, we can declare a useFormContext and let the components use just that.

import React, { createContext, useContext } from 'react';

const FormContext = createContext([{}, () => null]);

function FormProvider(props) {
  const [formState, setFormState] = useState({
    name: "",
    surname: ""
  });

  return (
    <FormContext.Provider 
      value={[formState, setFormState]}
    >
      {props.children}
    </FormContext.Provider>
  );
}

const useFormContext = () => useContext(FormContext);

export default { FormProvider, useFormContext };

And then we can use it in our components like:

import React from 'providers/FormProvider';
import { useFormContext } from 'providers/FormProvider';

function Form() {
  const [formState, setFormState] = useFormContext();

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={
            e => setFormState({ 
              ...formState,
              name: e.target.value
            })
          }
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={e =>
            setFormState({
              ...formState,
              surname: e.target.value
            })
          }
        />
      </div>
    </form>
  );
}

That makes it much easier to mock our context in the tests, with something like:

import * as FormProvider from 'providers/FormProvider';

jest.spyOn(FormProvider, 'useFormContext')
    .mockImplementation(...);
  • Another problem that I see in the previous way is that we are exposing the setFormState to our components, and the components are responsible for manipulating the state the way they want. I don’t find ideal having business logic inside presentational components.

Solution 3

One way to fix the previous issue is to implement the field specific functions in the provider and pass them to the components. Something like:

function FormProvider(props) {
  const [formState, setFormState] = useState({
    name: "",
    surname: ""
  });

  const setName = e => 
    setFormState({ 
      ...formState,
      name: e.target.value
    });

  const setSurname = e => 
    setFormState({ 
      ...formState,
      surname: e.target.value
    });

  return (
    <FormContext.Provider
      value={[formState, setName, setSurname]}
    >
      {props.children}
    </FormContext.Provider>
  );
}

And use it our form component like:

function Form() {
  const [formState, setName, setSurname] = useFormContext();

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={setName}
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={setSurname}
        />
      </div>
    </form>
  );
}
  • Obviously this do not scale well, the more fields we have the more functions we need to add. It’s not ideal. Another problem is that our setName and setSurname functions are not pure, they depend on the closure to access the formState. They cannot be tested in isolation.

Solution 4

We can solve the scaling issue described previously, using the useReducer and pass the dispatch function to the components.

const initialState = {
  name: '',
  surname: '',
};

const reducer = (state, action) => {
  const { type, payload } = action;

  switch (type) {
    case 'set_name':
      return {
        ...state,
        name: payload,
      };
    case 'set_surname':
      return {
        ...state,
        surname: payload,
      };
    default:
      return state;
  }
}

function FormProvider(props) {
  const [formState, dispatch] = useReducer(reducer, initialState);

  return (
    <FormContext.Provider value={[formState, dispatch]}>
      {props.children}
    </FormContext.Provider>
  );
}

And in our component we call the dispatch with the right type.

function Form() {
  const [formState, dispatch] = useFormContext();

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={e => dispatch({ type: 'set_name', payload: e.target.value})}
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={e => dispatch({ type: 'set_surname', payload: e.target.value})}
        />
      </div>
    </form>
  );
}
  • What I don’t like in this approach is that the components have to know the type which makes them highly coupled with the Provider/reducer. I would prefer my components to not have to know this.

Async operations

Let’s now imagine that we would like to do some async operation e.g. logging or tracking keystrokes before actually setting the state. Reading around I have seen proposed solutions that invole some sort of middleware e.g.

Solution 5

function dispatchMiddleware(dispatch) {
  return (action) => {
    switch (action.type) {
      case 'set_name':
        // do some async operation and the call the callback with dispatch
        async_log_keystroke(action.payload, () => dispatch(action));
        break;

      default:
        return dispatch(action);
    }
  };
}

Provider:

function FormProvider(props) {
  const [formState, dispatch] = useReducer(reducer, initialState);

  return (
    <FormContext.Provider value={[formState,dispatchMiddleware(dispatch)]}>
      {props.children}
    </FormContext.Provider>
  );
}

const useFormContext = () => useContext(FormContext);

Component:

function Form() {
  const [formState, dispatch] = useFormContext();

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={e => dispatch({ type: 'set_name', payload: e.target.value})}
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={e => dispatch({ type: 'set_surname', payload: e.target.value})}
        />
      </div>
    </form>
  );
}
  • I personally find it overcomplicated to have to implement this sort of middleware.
  • Again our components have to know the types.

Solution 6

Another approach I have seen is using the useEffect in the Provider and “watching” the state trigger async operations.

e.g.

  useEffect(() => {
    if (!state.hasInputChange) {
      return;
    }
     async_log_keystroke(action.payload, () => dispatch(action));
  }, [state.hasInputChange]);

Again I find that not so nice.

My Approach

My approach to solve the previous problem is to split the problem.

  • Keep async functions in a seperate module, which I call services
  • All the components use Contexes that have the same signature

First I create a module/file that I call services and where all the async calls are happening e.g. calling an endpoint to track user actions.

UserService.js:

export function async logUser(data){
  try {
    const response = await axios.post(..., data);
    return response.data;
  } catch(e) {
    return Promise.reject(e);
  }
}

Inside my Provider I create an actions object that is passed to the components and holds all the functions that can change the state.

Provider:

const initialState = {
  name: '',
  surname: '',
  isRequestInProgress: false,
};

const reducer = (state, action) => {
  const { type, payload } = action;

  switch (type) {
    case 'set_name':
      return {
        ...state,
        isRequestInProgress: true,
        error: null,
        name: payload,
      };
    case 'set_surname':
      return {
        ...state,
        isRequestInProgress: true,
        error: null,
        surname: payload,
      };
    case 'error':
      return {
        ...state,
        error: payload,
      };
    case 'is_request_in_progress':
      return {
        ...state,
        isRequestInProgress: payload,
      };
    default:
      return state;
  }
}

let actions = {};

function FormProvider(props) {
  const [formState, dispatch] = useReducer(reducer, initialState);

  actions.setName = useCallback(async (name) => {
    try {
      await logUser(name);
      dispatch({ type: 'set_name', payload: 'name' })
    } catch(e){
      dispatch({ type: 'error', payload: error }) 
    } finally {
      dispatch({ type: 'is_request_in_progress', payload: false }) 
    }

  }, []);

  ...
  ...

  return (
    <FormContext.Provider value={[formState, actions]}>
      {props.children}
    </FormContext.Provider>
  );
}

Component:

function Form() {
  const [formState, actions] = useFormContext();
  const { setName, setSurname } = actions;

  return (
    <form>
      <div>
        <label>Name</label>
        <input
          value={formState.name}
          onChange={e => setName(e.target.value)}
        />
      </div>
      <div>
        <label>Surname</label>
        <input
          value={formState.surname}
          onChange={e => setSurname(e.target.value)}
        />
      </div>
    </form>
  );
}
  • My components does not need to know how the state is changing or what happens before/after it change.
  • Dispatch is called in a single place, inside the Provider as opposed to using a middleware.
  • The component do not need to know implementation details like the types that have to been dispatched.
  • I can attach as many functions as I want to the actions object without making it difficult to read.
  • The mental flow is easier compared to using the useEffect as a way to trigger async operations based on state changes.
  • Declaring my reducer outside of the provider helps me to test it in isolation.

Note: For the sake of simplicity, I omitted any performance optimization.

Published 6 Oct 2019

Engineering Manager. Opinions are my own and not necessarily the views of my employer.
Avraam Mavridis on Twitter