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.
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>
);
}
FormContext
and the useContext
. 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(...);
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.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>
);
}
setName
and setSurname
functions are not pure,
they depend on the closure to access the formState
. They cannot be tested in isolation.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>
);
}
type
which makes them highly coupled with the Provider/reducer. I would prefer my components
to not have to know this.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.
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>
);
}
types
.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 to solve the previous problem is to split the problem.
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>
);
}
actions
object without making it difficult to read.useEffect
as a way to trigger async operations based
on state changes.