This post acts as notes for a course called “State Management in Pure React” on the FrontendMaster. The purpose of writing this post to cement the concepts.
The sample application is located here: https://codesandbox.io/s/grudge-list-forked-3gyxo?file=/src/Application.js Open the link and play around with it. The architecture of the application can be represented as following:
The application mimicks a todo list application. Instead of todo list we have a grudge list :). It contains a list of people who have wronged us. There is option to forgive them and add a new item to the list. I would recommend to playaround with it to get familiar.
The code for application.js
is:
const Application = () => {
const [grudges, setGrudges] = useState(initialState);
const addGrudge = (grudge) => {
grudge.id = id();
grudge.forgiven = false;
setGrudges([grudge, ...grudges]);
};
const toggleForgiveness = (id) => {
setGrudges(
grudges.map((grudge) => {
if (grudge.id !== id) return grudge;
return { ...grudge, forgiven: !grudge.forgiven };
})
);
};
return (
<>
<Log name="Application" />;
<div className="Application">
<NewGrudge onSubmit={addGrudge} />
<Grudges grudges={grudges} onForgive={toggleForgiveness} />
</div>
</>
);
};
export default Application;
We use useState
for state management. We have two methods addGrudge
and toggleForgiveness
.
addGrudge
takes the current array of object and a new object and created a new array by merging the existing array of object with the new object and passes it to the setGrudges
.
toggleForgiveness
receives id
of the grudge object, and toggles the forgiven
flag and passes the setGrudges
a new array.
The value for initialState
state is an array of objects. You can open the initialState
file for more details
[
{
"id": "14b35d08-9980-4b70-93e5-faa736168c35",
"person": "Meta",
"reason": "Parked too close to me in the parking lot",
"forgiven": false
},
{
"id": "20642da3-309a-4cda-a98f-7471735f50db",
"person": "Ibbie",
"reason": "Did not brew another pot of coffee after drinking the last cup",
"forgiven": false
},
]
The application.js
has two child components.
- NewGrudge
- Grudges
NewGrudge
It is a component which has two html input
s and button. It takes a name and reason via the input
and stores them locally using useState
.
The component takes a function name onSubmit
as a prop and whenever user hits the submit
button it calls that callback with person
and reason
as a parameter.
Here is the function for the sake of completion
const NewGrudge = ({ onSubmit }) => {
const [person, setPerson] = useState('');
const [reason, setReason] = useState('');
const handleChange = event => {
event.preventDefault();
onSubmit({ person, reason });
};
return (
<form className="NewGrudge" onSubmit={handleChange}>
<input
className="NewGrudge-input"
placeholder="Person"
type="text"
value={person}
onChange={event => setPerson(event.target.value)}
/>
<input
className="NewGrudge-input"
placeholder="Reason"
type="text"
value={reason}
onChange={event => setReason(event.target.value)}
/>
<input className="NewGrudge-submit button" type="submit" />
</form>
);
};
export default NewGrudge;
Grudges
This components takes two props, one an array of grudges called grudges
and the second a callback called onForgive
.
The array of grudges has following structure:
[
{
"id": "14b35d08-9980-4b70-93e5-faa736168c35",
"person": "Meta",
"reason": "Parked too close to me in the parking lot",
"forgiven": false
},
{
"id": "20642da3-309a-4cda-a98f-7471735f50db",
"person": "Ibbie",
"reason": "Did not brew another pot of coffee after drinking the last cup",
"forgiven": false
},
]
This generated automatically you can view the code initialState.js
The Grudges
in turn itself has a single child component Grudge
. For each object in the grudges array, a Grudge
component is instantiated.
The code for Grudges
component:
const Grudges = ({ grudges = [], onForgive }) => {
return (
<section className="Grudges">
<h2>Grudges ({grudges.length})</h2>
{grudges.map(grudge => (
<Grudge key={grudge.id} grudge={grudge} onForgive={onForgive} />
))}
</section>
);
};
export default Grudges;
The Grudge
component takes two props, one called grudge
which represents an individual grudge item and second a callback onForgive
.
This component renders the person name, the grudge and a input
with checkbox to show whether grudge is forgiven or not and whenever the grudge input is toggled the onForgive
prop is called.
const Grudge = ({ grudge, onForgive }) => {
const forgive = () => onForgive(grudge.id);
return (
<article className="Grudge">
<h3>{grudge.person}</h3>
<p>{grudge.reason}</p>
<div className="Grudge-controls">
<label className="Grudge-forgiven">
<input type="checkbox" checked={grudge.forgiven} onChange={forgive} />{' '}
Forgiven
</label>
</div>
</article>
);
};
export default Grudge;
Lets create a new component called Log
like the name it will simply log to the console whenver it is rendered.
const Log = (name) => {
console.log('rendering ', name);
return null;
};
export default Log;
Lets import and use it in all the components.
So lets type something in the inputs for adding a grudge. Notice name
and reason
is being type only the the NewGrudge
component is being re-rendered, but as soon submit button is clicked the whole application is re-rendered including all the instances of the Grudge
component. The reason this is happening is because the setState
inside Application
triggers a re-render, this re-renders starts at root and continues throughout the application.
Note that in setState
we are not mutating the existing array or object instead we are creating a new array. This is because the setState
function compares the previous and current values and only triggers re-render if they are different. If they are same it does not trigger the re-rendering process, so its important that if you are using non-primitive values then you should not mutate them. You can also use libraries like immer.js
to avoid mutating objects.
Now lets implement the same functionality using the useReducer
hook. lets create reducer first:
const GRUDGE_ADD = 'GRUDGE_ADD';
const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';
const reducer = (state = [], action) => {
if (action.type === GRUDGE_ADD) {
return [
{
id: id(),
...action.payload
},
...state
];
}
if (action.type === GRUDGE_FORGIVE) {
return state.map(grudge => {
if (grudge.id === action.payload.id) {
return { ...grudge, forgiven: !grudge.forgiven };
}
return grudge;
});
}
return state;
};
Now we need to hook up this reducer with application. We can do this by using the react useReducer
hook.
const [grudges, dispatch] = useReducer(reducer, initialState);
We will use this hook to replace existing useState
as we are handling the logic of adding grudge and forgiveness inside the reducer, we need to update the existing addGrudge
and toggleForgiveness
to dispatch an appropriate action to the reducer, and the reducer will handle the rest so lets code this:
const addGrudge = ({ person, reason }) => {
dispatch({
type: GRUDGE_ADD,
payload: {
person,
reason,
forgiven: false,
id: id()
}
});
}
const toggleForgiveness = id => {
dispatch({
type: GRUDGE_FORGIVE,
payload: { id }
});
}
Lets now try again adding a new grudge and toggle forgiveness, you will notice same logs in console as before :( no improvement!.
When user types something inside the input
located in the NewGrudge
component the state of the component is updated and a trigger is re-rendered inside the component.
Now when user clicks on the submit button, addGrudge
is called which dispatches an action, this causes a re-render to start in root application and run all over the compononents hirerachy. Same thing happens when we toggle forgiveness inside the Grudge
component.
So how can we avoid re-render. react documentation points towards a memo
function. This function takes a component and memoizes its i.e it does not re-render the component unless its props changes. We can use this memo
function inside our NewGrudge
and Grudge
component.
To use memo, just wrap the component with memo
.
NewGrudge
after memo:
import React, { useState } from 'react';
import Log from './Log';
const NewGrudge = React.memo(({ onSubmit }) => {
const [person, setPerson] = useState('');
const [reason, setReason] = useState('');
const handleChange = (event) => {
event.preventDefault();
onSubmit({ person, reason });
};
return (
<>
<Log name="NewGrudge" />
<form className="NewGrudge" onSubmit={handleChange}>
<input
className="NewGrudge-input"
placeholder="Person"
type="text"
value={person}
onChange={(event) => setPerson(event.target.value)}
/>
<input
className="NewGrudge-input"
placeholder="Reason"
type="text"
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
<input className="NewGrudge-submit button" type="submit" />
</form>
</>
);
});
export default NewGrudge;
Similarly Grudge
after memo:
import React from 'react';
import Log from './Log';
const Grudge = React.memo(({ grudge, onForgive }) => {
const forgive = () => onForgive(grudge.id);
return (
<>
<Log name={`Grudge ${grudge.person}`} />
<article className="Grudge">
<h3>{grudge.person}</h3>
<p>{grudge.reason}</p>
<div className="Grudge-controls">
<label className="Grudge-forgiven">
<input
type="checkbox"
checked={grudge.forgiven}
onChange={forgive}
/>{' '}
Forgiven
</label>
</div>
</article>
</>
);
});
export default Grudge;
Even now if we check the console
for logs, we will see there is still no improvement. This is because the memo
function compares the props
passed to previous invocation with props
passed to current invocation and in our application the props change. Whenever the application is re-rendered the following code is executed:
const addGrudge = ({ person, reason }) => {
dispatch({
type: GRUDGE_ADD,
payload: {
person,
reason,
forgiven: false,
id: id()
}
});
}
const toggleForgiveness = id => {
dispatch({
type: GRUDGE_FORGIVE,
payload: { id }
});
}
In this piece of code we create a new function on each re-render and assign it to variables. To avoid this we can wrap the addGrudge
and toggleForgiveness
inside useCallback
hook. This will make sure that a new function is not created on every re-render.
const addGrudge = useCallback(
({ person, reason }) => {
dispatch({
type: GRUDGE_ADD,
payload: {
person,
reason,
forgiven: false,
id: id()
}
});
},
[dispatch]
);
const toggleForgiveness = useCallback(
(id) => {
dispatch({
type: GRUDGE_FORGIVE,
payload: { id }
});
},
[dispatch]
);
Now lets type something in the inputs
and hit submit. You will notice that only application
Grudges
and Grudge
component is re-rendered. The difference is that before in addition to these all the Grudge
component would also re-rendered.
Now lets try to toggle the forgiveness
checkbox. You will notice the instead of re-rendering all the Grudge
components, only a single Grudge
component will be re-rendered. Here is the link if you want to test: https://codesandbox.io/s/grudge-list-reducer-vmx9h?file=/src/Application.js
So why the need for all of this useReducer
could not have we done the same thing with setState
.
const [grudges, setGrudges] = useState(initialState);
const setGrudges = grudge => {
grudge.id = id();
grudge.forgiven = false;
setGrudges([grudge, ...grudges]);
};
const toggleForgiveness = id => {
setGrudges(
grudges.map(grudge => {
if (grudge.id !== id) return grudge;
return { ...grudge, forgiven: !grudge.forgiven };
})
);
};
We could have used useCallback
in the above code but since useCallback
would dependecies on grudges
and setGrudges
we would have gotten a new referance to setGrudges
and toggleForgiveness
on each re-render.
useContext
In our application we prop drilling our way from application.js
-> Grudges
-> Grudge
. We can avoid this prop drilling by using the reacts context api. First we need to extract the reducers
and functions which dispatches the action into a separate file. In this new file will we create a context and pass these functions to the context. Later on we will wrap this context around NewGrudge
and Grudges
component.
contents of GrudgeContext.js
import React, { useReducer, createContext, useCallback } from 'react';
import initialState from './initialState';
import id from 'uuid/v4';
export const GrudgeContext = createContext();
const GRUDGE_ADD = 'GRUDGE_ADD';
const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';
const reducer = (state = [], action) => {
if (action.type === GRUDGE_ADD) {
return [
{
id: id(),
...action.payload
},
...state
];
}
if (action.type === GRUDGE_FORGIVE) {
return state.map(grudge => {
if (grudge.id === action.payload.id) {
return { ...grudge, forgiven: !grudge.forgiven };
}
return grudge;
});
}
return state;
};
export const GrudgeProvider = ({ children }) => {
const [grudges, dispatch] = useReducer(reducer, initialState);
const addGrudge = useCallback(
({ person, reason }) => {
dispatch({
type: GRUDGE_ADD,
payload: {
person,
reason
}
});
},
[dispatch]
);
const toggleForgiveness = useCallback(
id => {
dispatch({
type: GRUDGE_FORGIVE,
payload: {
id
}
});
},
[dispatch]
);
return (
<GrudgeContext.Provider value={{ grudges, addGrudge, toggleForgiveness }}>
{children}
</GrudgeContext.Provider>
);
};
contents of application.js
import React from 'react';
import Grudges from './Grudges';
import NewGrudge from './NewGrudge';
const Application = () => {
return (
<div className="Application">
<NewGrudge />
<Grudges />
</div>
);
};
export default Application;
contents of index.js
ReactDOM.render(
<GrudgeProvider>
<Application />
</GrudgeProvider>,
rootElement
);
Now the next step is to use the functions provided by context instead of using the one passed by props.
So Grudges
component after the the modification will look like this:
import React from 'react';
import Grudge from './Grudge';
import { GrudgeContext } from './GrudgeContext';
const Grudges = () => {
const { grudges } = React.useContext(GrudgeContext);
return (
<section className="Grudges">
<h2>Grudges ({grudges.length})</h2>
{grudges.map(grudge => (
<Grudge key={grudge.id} grudge={grudge} />
))}
</section>
);
};
export default Grudges;
Note that we are using useContext
to get access to the GrudgeContext
. We have also removed memo
because it is not required anymore.
Now lets take a look at Grudge
component:
import React from 'react';
import { GrudgeContext } from './GrudgeContext';
const Grudge = ({ grudge }) => {
const { toggleForgiveness } = React.useContext(GrudgeContext);
return (
<article className="Grudge">
<h3>{grudge.person}</h3>
<p>{grudge.reason}</p>
<div className="Grudge-controls">
<label className="Grudge-forgiven">
<input
type="checkbox"
checked={grudge.forgiven}
onChange={() => toggleForgiveness(grudge.id)}
/>{' '}
Forgiven
</label>
</div>
</article>
);
};
export default Grudge;
Similarly to the Grudges
component we are reading value from useContext
instead of passing down prop.
Changes for NewGrudge
const NewGrudge = () => {
const [person, setPerson] = React.useState('');
const [reason, setReason] = React.useState('');
const { addGrudge } = React.useContext(GrudgeContext);
const handleSubmit = event => {
event.preventDefault();
addGrudge({
person,
reason
});
};
return (
// …
);
};
export default NewGrudge;
This is all that required to move our application to use context. The advantage of this approach is that we do not have to do prop drilling any more but we the disadvantage is that we lose the performance gains. If you open console you will notice that the rendering behvaiour is same as that of before memo
.
Creating a custom hook for fetching data.
const useFetch = url => {
const [response, setResponse] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
setResponse(null);
fetch(url)
.then(response => response.json())
.then(response => {
setResponse(response);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [url]);
return [response, loading, error];
};
Lets see how can we handle with async
functions:
useEffect(() => {
console.log('Fetching');
setLoading(true);
setError(null);
setResponse(null);
const get = async () => {
try {
const response = await fetch(url);
const data = await response.json();
setResponse(formatData(data));
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
};
get();
}, [url, formatData]);
return [response, loading, error];
};
We are using setState
inside our we can refactor it to use useReducer
const fetchReducer = (state, action) => {
if (action.type === 'FETCHING') {
return {
result: null,
loading: true,
error: null,
};
}
if (action.type === 'RESPONSE_COMPLETE') {
return {
result: action.payload.result,
loading: false,
error: null,
};
}
if (action.type === 'ERROR') {
return {
result: null,
loading: false,
error: action.payload.error,
};
}
return state;
};
and we can use the reducer hook like this:
const useFetch = (url, dependencies = [], formatResponse = () => {}) => {
const [state, dispatch] = useReducer(fetchReducer, {
result: null,
loading: true,
error: null,
});
useEffect(() => {
dispatch({ type: 'FETCHING' });
fetch(url)
.then(response => response.json())
.then(response => {
dispatch({
type: 'RESPONSE_COMPLETE',
payload: { result: formatResponse(response) },
});
})
.catch(error => {
dispatch({ type: 'ERROR', payload: { error } });
});
}, [url, formatResponse]);
const { result, loading, error } = state;
return [result, loading, error];
};