This course looks deep into understanding state, the inner workings of this.setState
, exploring APIs to navigate around prop-drilling, reducers for advanced state management, write custom hooks, store state in local storage, store state in URL query params and fetch from a server.
This warm up just looks at the old setState
with class components that we know.
import React, { Component } from 'react'; import { render } from 'react-dom'; import './styles.scss'; class Application extends Component { state = { count: 0 }; increment = () => { this.setState({ count: this.state.count + 1, }); }; decrement = () => { this.setState({ count: this.state.count - 1, }); }; render() { const { count } = this.state; return ( <main className="Counter"> <p className="count">{count}</p> <section className="controls"> <button onClick={this.increment}>Increment</button> <button onClick={this.decrement}>Decrement</button> </section> </main> ); } } render(<Application />, document.getElementById('root'));
While different when it comes to hooks, React will batch up setState
calls to avoid unnecessary re-renders.
setState
also runs asynchronously - important to know!
setState
also allows you to pass in a function that enables you to ensure there is no batching. That function can take arguments (state, props)
. This helps us to abstract functions that can reused everywhere.
Finally, there is a callback that you can pass as a second argument to setState
that allows you to fire a function after the change. This is useful for when you are breaking encapsulation or need to pass data back to a parent after the change.
Fun tidbit that came up is that arrow function methods are not supported natively without a Babel plugin.
This gives a way to manipulate state in functional components. With this we don't have to worry about event binding. This is not disimilar to setState
asynchronous call.
const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1); const reset = () => setCount(0);
We can also take a function for this ie setCount(c => c + 1)
. This does not get a prop as a second argument. That means we can still pull out the function as long as we don't need access to props.
Here, we also need to pass something tangible back ie no undefined
returns from the callback.
We don't have a callback, but we can now use useEffect
as a replacement.
const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1); const reset = () => setCount(0); useEffect(() => { document.title = `Counter: ${count}`; }, [count]); // this second effect is an example of an effect for local storage useEffect(() => { storeStateInLocalStorage(count); }, count);
Note: if you don't give useEffect
a second parameter, it will run on every render.
If you give one, it gives dependencies where the effect will only run when there is a change to the dependency.
// this is an abstraction for React.useState const useLocalStorage = (initialState, key) => { const get = () => { const storage = localStorage.getItem('counterState'); if (storage) return JSON.parse(storage)[key]; return initialState; }; const [value, setValue] = useState(get()); useEffect(() => { localStorage.setItem(key, JSON.stringify({ value })); }, value); return [value, setValue]; };
This makes things a bit more useable across the app:
const Counter = () => { const [count, setCount] = useLocalStorage(0, 'count'); // Rest removed for brevity };
In the case of the function components, we have a unique call of the function each time which will give a different copy of the state and props.
Using a reference is a way for us to persist a previous value in our function components.
const Counter = () => { const [count, setCount] = useLocalStorage(0, 'count'); const countRef = React.useRef(); let message = ''; if (countRef.current < count) message = 'Higher'; if (countRef.current > count) message = 'Lower'; // the only way to update the current reference countRef.current = count; // Rest removed for brevity };
In this example, we see that there is always a new interval called when the useEffect runs:
useEffect(() => { setInterval(() => { console.log(`Count: ${count}`); }, 1000); }, [count]);
The above has no cleanups. No bueno. Those suttle things will come to bite us. To avoid this:
useEffect(() => { const id = setInterval(() => { console.log(`Count: ${count}`); }, 1000); // this will give a callback where we can cleanup // after itself for each effect return () => clearInterval(id); }, [count]);
React these days have a useReducer
which takes a simplified version of what Redux does.
React state needs immutable objects everytime. If you mutate an object or array, it is the same object in memory - React can't tell the difference. We know this already from the prominent use of passing new arrays and objects with the spread operator.
The example used is what happens when we update parent state for one child and all the children re-rendering needlessly.
A reducer
is a function that takes two arguments. It takes two arguments: 1. The current state of the world (state) and 2. The thing that happened (action).
It takes these two things and pipes out a resolved new state of the world.
const initialState = {}; const reducer = (state, action) => { if (action.type === TYPE) { // this assumes state is just an array for a list etc return [action.payload, ...state]; } return state; }; // code to run const Component = () => { const [dataNamedWhatever, dispatch] = useReducer(reducer, initialState); const actionToDo = mutation => { dispatch({ type: TYPE, payload: mutation, }); }; };
The benefit of this is that is becomes super easy to unit test. This means we don't need to worry about mounting components to do so.
Steve mentions that his preference is to not use a switch statement.
At this point, what we want to do is tell React not to rerender if nothing has changed.
React.memo
simply takes a function component, and if it has the same props, simply don't rerender it.
We also have useCallback
and useMemo
.
useMemo
will call a function and if the dependencies haven't changed, it will not call the function again.
useCallback
will give you a new memoized function that you can call. It changes only if one the dependencies has changed.
const initialState = {}; const reducer = (state, action) => { if (action.type === TYPE) { // this assumes state is just an array for a list etc return [action.payload, ...state]; } return state; }; // code to run const Component = () => { const [dataNamedWhatever, dispatch] = useReducer(reducer, initialState); const actionToDo = mutation => { dispatch({ type: TYPE, payload: mutation, }); }; };
If we wrap our NewGrudge
with React.memo
, then it will check
const Component = () => { const [dataNamedWhatever, dispatch] = useReducer(reducer, initialState); // this changes to make sure the function is memoized and only changes // if value of "mutation" changes const actionToDo = useCallback( mutation => { dispatch({ type: TYPE, payload: mutation, }); }, [dispatch], ); }; const List = React.memo(({ onSubmit }) => { // rest of code });
If we get the same function every time, we don't really need to rerender. This works because now our useCallback
only depends on dispatch
and not the mutation
value.
The aim of Context API is to be "hey, I want the state to be available around the place so that I can hook into".
An interesting question that come up about Redux out of the box. Redux comes with middleware out of the box,
useReducer
does not. Redux hascombinedReducers
, others do not. The tradeoff is with you need them and whether you use what you write vs what is battle tested etc.
React.createContext()
will give you a Provider
and a Consumer
. If you use hooks, you might be able to get away without using the consumer.
import React from 'react'; const SuperCoolContext = React.createContext(); SuperCoolContext.Provider; SuperCoolContext.Consumer; // in use (this uses the Render Prop pattern) const App = () => ( <SuperCoolContext.Provider value={0}> <SuperCoolContext.Consumer> {value => <p>{value}</p>} </SuperCoolContext.Consumer> </SuperCoolContext.Provider> );
The example given ends up pulling all the reducer actions into a GrudgeContext.js
file with the reducer.
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> ); };
And so the demo Application.js
file becomes slim:
import React from 'react'; import Grudges from './Grudges'; import NewGrudge from './NewGrudge'; const Application = () => { return ( <div className="Application"> <NewGrudge /> <Grudges /> </div> ); }; export default Application;
Now we can update the Grudges.js
list:
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;
And finally update the individual 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;
Notice that we are losing some of the optimisations that we made here + the ability to unit test smaller parts.
When it comes to testing, you now need to pass the a provider. You can use the container
pattern or HOC
pattern to "hug" these changes to help.
The question is whether or not we need to bring in any heavy-weight tools into our application like RxJS etc to help cancel and debounce API calls etc.
Creating a custom hook for a fetch:
const useFetch = url => { const [response, setResponse] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { console.log('Fetching'); 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]; };
Something that is important here is that we are handling cases for:
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; };
Now we can dispatch actions.
const useFetch = (url, dependencies = [], formatResponse = () => {}) => { const [state, dispatch] = useReducer(fetchReducer, initialState); 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]; };
Something we get in Redux is middleware. The reducer itself does not have an idea of asynchonity. It is genuinely other calls that force the async. Redux uses this thing called a thunk
. In normal reducers, we dispatch actions. A thunk itself is a function returned from another function.
function definitelyNotAThunk() { return function aThunk() { console.log('Hello I am a think.'); }; }
Using this allows us to separate the function from the UI and tells the reducer that does know stuff how to do it.
useThunkReducer = (reducer, initialState) => { const [state, dispatch] = useReducer(reducer, initialState); const enchancedDispatch = React.useCallback(action => { // example uses Lodash isFunction if (isFunction(action)) { action(dispatch); } else { dispatch(action); } }, [dispatch]); return [state, enchancedDispatch]; }; // in application const fetchCharacters = dispatch => { fetch(endpoint) .then(res => res.json()) .then(res => dispatch({ type: 'RES_COMPLETE' })); }; const Application = () => { const [state, dispatch] = useThunkReducer(reducer, initialState); // If we want it on load // useEffect(() => { // dispatch(fetchCharacters) // }, []); render() { return (<button onClick={() => dispatch(fetchCharacters)}>Click</button>) } };
Something worth noting here is that Steve mentions that some of the previous performance optimisations that we lost prior can actually be tweaked with react-redux.
This section talks to the perils of useEffect
and dispatch
found here
For React Router, we have perils to be careful of with the dependencies:
const CharacterView = ({ match }) => { const [character, setCharacter] = useState({}); useEffect(() => { fetch(endpoint + '/characters/' + match.params.id) .then(response => response.json()) .then(response => setCharacter(response.character)) .catch(console.error); }, [match.params.id]); // ... };