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.

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 theuseContext
. - 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
andsetSurname
functions are not pure, they depend on the closure to access theformState
. 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.