We're starting out with a basic version of an application that uses hooks to manage state.
There are two issues that we'd like to solve for.
- Prop drilling:
Grudges
needs to receivetoggleForgiveness
even though it will never use it. It's just passing it down toGrudge
. - Needless re-renders: Everything re-renders even when we just check a single checkbox. We could try to get clever with some of React's performance helpers—or we can just manage our state better.
- Turn on the "Highlight updates when components render." feature in the React developer tools.
- Notice how a checking a checkbox re-renderers everything.
- Notice how this is not the case in
NewGrudge
.
We could try to get clever here with useCallback
and React.memo
, but since we're always replacing the array of grudges, this is never really going to work out.
What if we took a different approach to managing state?
Let's make a new file called reducer.js
.
const reducer = (state = [], action) => {
return state;
};
And then we swap out that useState
with a useReducer
.
const [grudges, dispatch] = useReducer(reducer, initialState);
We're going to create an action type and an action creator.
const GRUDGE_ADD = 'GRUDGE_ADD';
const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';
const addGrudge = ({ person, reason }) => {
dispatch({
type: GRUDGE_ADD,
payload: {
person,
reason
}
});
};
We'll add it to the reducer.
const reducer = (state = [], action) => {
if (action.type === GRUDGE_ADD) {
return [
{
id: id(),
...action.payload
},
...state
];
}
return state;
};
Let's make an action creator
const forgiveGrudge = id => {
dispatch({
type: GRUDGE_FORGIVE,
payload: {
id
}
});
};
We'll also update the reducer here.
if (action.type === GRUDGE_FORGIVE) {
return state.map(grudge => {
if (grudge.id === action.payload.id) {
return { ...grudge, forgiven: !grudge.forgiven };
}
return grudge;
});
}
We'll thread through forgiveGrudge
as onForgive
.
<button onClick={() => onForgive(grudge.id)}>Forgive</button>
That prop drilling isn't great, but we'll deal with it in a bit.
- Wrap the action creators in
useCallback
- Wrap
NewGrudge
andGrudge
inReact.memo
- Notice how we can reduce re-renders
The above example wasn't too bad. But, you can see how it might get a bit out of hand as our application grows.
What if two very different distant cousin components needed the same data?
Modern builds of React allow you to use something called the Context API to make this better. It's basically a way for very different pieces of your application to communicate with each other.
We're going to rip a lot out of Application.js
and move it to a new file called GrudgeContext.js
and it's going to look something like this.
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>
);
};
Now, Application.js
looks a lot more slimmed down.
import React from 'react';
import Grudges from './Grudges';
import NewGrudge from './NewGrudge';
const Application = () => {
return (
<div className="Application">
<NewGrudge />
<Grudges />
</div>
);
};
export default Application;
ReactDOM.render(
<GrudgeProvider>
<Application />
</GrudgeProvider>,
rootElement
);
That works and it's cool, but it's still missing the point.
So, we don't need that pass through on Grudges
anymore. Let's rip that out completely.
import React from 'react';
import Grudge from './Grudge';
const Grudges = ({ grudges = [] }) => {
return (
<section className="Grudges">
<h2>Grudges ({grudges.length})</h2>
{grudges.map(grudge => (
<Grudge key={grudge.id} grudge={grudge} />
))}
</section>
);
};
export default Grudges;
But, we will need to tell it about the grudges so that it can iterate through them.
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;
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;
In this case, we just need the ability to add a grudge.
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;
{
past: [allPastStates],
present: currentStateOfTheWorld,
future: [anyAndAllFutureStates]
}
- We lost all of our performance optimizations.
- It's a trade off.
- Grudge List might seem like a toy application, but it could also represent a smaller part of a larger system.
- Could you use the Context API to get things all of the way down to this level and then use the approach we had previous?
Okay, so that array stuff is a bit wonky.
What if we used an object?
All of this is going to happen in GrudgeContext.js
.
What if our data was structured more like this?
const defaultGrudges = {
1: {
id: 1,
person: name.first(),
reason: 'Parked too close to me in the parking lot',
forgiven: false
},
2: {
id: 2,
person: name.first(),
reason: 'Did not brew another pot of coffee after drinking the last cup',
forgiven: false
}
};
export const GrudgeProvider = ({ children }) => {
const [grudges, setGrudges] = useState({});
const addGrudge = grudge => {
grudge.id = id();
setGrudges({
[grudge.id]: grudge,
...grudges
});
};
const toggleForgiveness = id => {
const newGrudges = { ...grudges };
const target = grudges[id];
target.forgiven = !target.forgiven;
setGrudges(newGrudges);
};
return (
<GrudgeContext.Provider
value={{ grudges: Object.values(grudges), addGrudge, toggleForgiveness }}
>
{children}
</GrudgeContext.Provider>
);
};
We need to think about the past, present, and future.
const defaultState = {
past: [],
present: [],
future: []
};
We've broken almost everything. So, let's make this a bit better.
const reducer = (state, action) => {
if (action.type === ADD_GRUDGE) {
return {
past: [],
present: [
{
id: uniqueId(),
...action.payload
},
...state.present
],
future: []
};
}
if (action.type === FORGIVE_GRUDGE) {
return {
past: [],
present: state.present.filter(grudge => grudge.id !== action.payload.id),
future: []
};
}
return state;
};
past: [state.present, ...state.past];
if (action.type === UNDO) {
const [newPresent, ...newPast] = state.past;
return {
past: newPast,
present: newPresent,
future: [state.present, ...state.present]
};
}
const undo = useCallback(() => {
dispatch({ type: UNDO });
}, [dispatch]);
<button disabled={!state.past.length} onClick={undo}>
Undo
</button>
if (action.type === REDO) {
const [newPresent, ...newFuture] = state.future;
return {
past: [state.present, ...state.past],
present: newPresent,
future: newFuture
};
}
const useUndoReducer = (reducer, initialState) => {
const undoState = {
past: [],
present: initialState,
future: []
};
const undoReducer = (state, action) => {
const newPresent = reducer(state, action);
if (action.type === UNDO) {
const [newPresent, ...newPast] = state.past;
return {
past: newPast,
present: newPresent,
future: [state.present, ...state.future]
};
}
if (action.type === REDO) {
const [newPresent, ...newFuture] = state.future;
return {
past: [state.present, ...state.past],
present: newPresent,
future: newFuture
};
}
return {
past: [state.present, ...state.past],
present: newPresent,
future: []
};
};
return useReducer(undoReducer, undoState);
};
Let's make a new file called connect.js
.
We'll start with some simple imports.
import React, { createContext, useReducer } from 'react';
import initialState from './initialState';
import id from 'uuid/v4';
Let's also pull in the action types and reducer from GrudgeContext.js
.
export const GRUDGE_ADD = 'GRUDGE_ADD';
export const GRUDGE_FORGIVE = 'GRUDGE_FORGIVE';
export 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;
};
We'll also want to create a context that we can use.
Alright, so now we'll make a new provider that will take the reducer's state and dispatch and thread it through the application.
Okay, let's make a generalized Provider
.
export const Provider = ({ reducer, initialState, children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
);
};
Next, we'll make the connect
function.
export const connect = (
mapStateToProps,
mapDispatchToProps
) => Component => ownProps => {
const { state, dispatch } = useContext(Context);
let stateProps = {};
let dispatchProps = {};
if (isFunction(mapStateToProps)) {
stateProps = mapStateToProps(state, ownProps);
}
if (isFunction(mapDispatchToProps)) {
dispatchProps = mapDispatchToProps(dispatch, ownProps);
}
Component.displayName = `Connected(${Component.displayName})`;
return <Component {...stateProps} {...dispatchProps} {...ownProps} />;
};
We're going to make three container functions:
NewGrudgeContainer
GrudgesContainer
GrudgeContainer
We're also going to need to rip the previous context out of.
import React from 'react';
import ReactDOM from 'react-dom';
import Application from './Application';
import { reducer, Provider } from './connect';
import initialState from './initialState';
import './styles.css';
const rootElement = document.getElementById('root');
ReactDOM.render(
<Provider reducer={reducer} initialState={initialState}>
<Application />
</Provider>,
rootElement
);
import { connect } from './connect';
import Grudges from './Grudges';
const mapStateToProps = state => {
console.log({ state });
return { grudges: state };
};
export default connect(mapStateToProps)(Grudges);
import { connect, GRUDGE_FORGIVE } from './connect';
import Grudge from './Grudge';
const mapDispatchToProps = (dispatch, ownProps) => {
return {
forgive() {
dispatch({
type: GRUDGE_FORGIVE,
payload: {
id: ownProps.grudge.id
}
});
}
};
};
export default connect(null, mapDispatchToProps)(Grudge);
import React from 'react';
import GrudgeContainer from './GrudgeContainer';
const Grudges = ({ grudges }) => {
return (
<section className="Grudges">
<h2>Grudges ({grudges.length})</h2>
{grudges.map(grudge => (
<GrudgeContainer key={grudge.id} grudge={grudge} />
))}
</section>
);
};
export default Grudges;
import React from 'react';
import { GrudgeContext } from './GrudgeContext';
const Grudge = React.memo(({ grudge, forgive }) => {
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;
Can you implement NewGrudgeContainer
?