Preface: This tutorial is what I would call intermediate to high level. It's not a starter javascript or react tutorial. So hang in there if you're low level, and do your best.
When working with React, one of the problems new devs come up against will be how React handles the structure and passing of data. It is a top down structure. Parents send data to children, and they never read data from children. Children are self contained and do their own thing. In fact, the react way is to have everything as self contained and ignorant as it can be. The only things it knows are what it's given. So how can children tell their parents information?
Generally this is done with setter functions passed down to children. These days that's all handled with hooks. Create a useState and pass both the value and method down into the children.
const Form = () => {
const [state, setState] = useState(true);
return <form><Checkbox {...{state, setState}} /></form>
}
const Checkbox = ({state, setState}) => {
return <input type="checkbox" checked={state} onChange={()=>{setState(!state)}} />
}
This is not only fine, it's correct... up to a certain point. But this really only looks good for simple examples and small parts of an application. Once an application reaches a particular size, this becomes unwieldy. Especially when encountering places where state has to be passed through multiple levels, or when you really only wanted siblings to be aware of each other, or when data is particularly complex. Then something else needs to be brought in to play. What's nice, is that there are a number of tools available. What's not nice is that so many of the examples found online are so over-engineered that it can be difficult to understand the gist of what's going on.
But first things first. Let's discuss the useReducer
. React generally wants you to use the useState hook for simple data. As all things programming go, that isn't the whole of it, but it's good enough for a first lesson. Want a boolean, or a string, or even an array? Use the useState
hook to set up a getter and setter for that watched value. But sometimes things can get out of hand.
const Form = () => {
const [name, setname] = useState("");
const [email, setemail] = useState("");
const [phone, setphone] = useState("");
const [city, setcity] = useState("");
const [state, setstate] = useState("");
{/* ... */}
}
You get the idea. So much data, so much boilerplate. There must be a simpler way. And what's worse? What if each one of these data points needs to be mutated or checked in the same way every time? The overhead on a simple form like this could be a lot. Now, technically you can use complex data with the useState
hook. You can even use a function callback to mutate the data, but it has to be done at the call end of the code. It comes down to convention sometimes. Conventionally useState
is for simple data.
const initial = {
name: "",
email: "",
phone: "",
city: "",
state: "",
};
const Form = () => {
const [form, setForm] = useState(initial);
const changeNameHandler = (e) => {
setForm({
...form,
name: e.target.value
});
}
{/* ... */}
}
This is where the useReducer
hook can come in, it can be used to make and handle much more complex data. Let's see THE simplest example there is. It involves a manipulator function, and and an initial data, just like useState
.
const Form = () => {
const [form, setForm] = useReducer(
(state, action) => ({...state, ...action}),
initial,
);
const changeNameHandler = (e) => {
setForm({name: e.target.value});
}
{/* ... */}
}
It might not be immediately clear what has been gained here. But notice that with the useState
, manipulation had to happen at the handler. But now manipulation occurs anytime the setForm is called, so the change handler is able to just pass its relevant data and let the manipulator handle it however it wants. This is the most basic benefit of useReducer
, but it goes further. Most examples online will talk about setting the action
to have indicators for what data to handle, and how to manipulate it. And truly that is where useReducer
becomes super powerful. But just this little example should turn you on to learning more.
By way of example, other more specific tutorials will show you how to use the action as strictly an instruction for how a large switch might manipulate the data.
const initial = { amount: 0 };
const formReducer = (state, action) => {
switch(action.type){
case "add": return { amount: state.amount + 1 };
case "subtract": return { amount: state.amount - 1 };
default: throw("No action matched");
}
}
const Form = () => {
const [form, setForm] = useReducer( formReducer, initial );
const handleAmountClick = () => {
setForm({type: 'add'});
}
{/* ... */}
}
Now let's discuss the useContext
. Keeping all your data bundled into one object is often needed. It is not the end all solution, and compartmentalization can still be good. But the next step that can be simplified is the data passing process. When using states and reducers, the getters and setters have to be passed from parent to children. If there are many disparate children, or a case where siblings need to be aware of the same data, or just a very deep component structure, passing setters and getters around can get cumbersome.
The useContext
hook will allow a high level component to be added to your app structure, and all lower level components the option to subscribe to some data from it. At its core it is extremely simple to set up.
export const initial = {
name: "",
email: "",
phone: "",
city: "",
state: "",
}
export const FormContext = createContext(initial);
export const Form = ({children}) => {
const formControls = useReducer((state,action)=>({...state, ...action}), initial);
return <FormContext.Provider value={formControls}>
<form>{children}</form>
</FormContext.Provider>
}
export const Input = ({label, field, ...props}) => {
const [form, setForm] = useContext(FormContext);
const changeHandler = (e)=>{
setForm({[field]: e.target.value});
}
return <input value={form[field]} onChange={changeHandler} {...props} placeholder={label} />
}
export const App = () => {
return <Form>
<Input label="Name" field="name" />
<Input label="Email" field="email" />
<Input label="Phone" field="phone" />
<Input label="City" field="city" />
<Input label="State" field="state" />
</Form>;
}
Even with the growing complexity of this code, it is still an extremely simplified example. But let's examine the relevant bits. When creating a top level component, a useReducer
(or whatever) is made as usual. At the same time, a createContext
is called and it's value is used as a wrapper component around all children. A Context.Provider
component is always passed a value attribute that sets what data all children can access. Then any child that wants to consume the shared data only has to utilize a useContext
hook with the relevant context object passed as argument. Whatever was the structure of the Provider will be the output of the useContext
.
There are a number of obfuscations that creep up all the time in examples seen in the react docs and tutorials online. Making a named specific useContext method can help future devs on a project to more easily know how to use your context.
const useFormContext = () => useContext(FormContext);
const Input = () => {
const [form,setForm] = useFormContext();
{/* ... */}}
}
This kind of thing is quite common in tutorials and examples, and can lead new devs to think it's necessary, but truly it isn't. It isn't necessary, even if on bigger projects it can be quite useful. Try not to get caught up in other devs' idosyncracies and learn to separate the necessary from the good ideas.
When subscribing to a useContext
, an element will get it's data from the closest Context.Provider
to it in its lineage. This means that it can be simple to reuse and customize each instance of a Provider. A basic example can be shown with the following. It gets harder and harder to show concise examples when it comes to the usefulness of context. It's most useful in sprawled out applications and small concise examples will always overly simplify. But let's give it a try.
const Store = createContext({});
const NameInput = () => {
const [name,setName] = useContext(Store);
return <input type="text" value={name} onChange={(e)=>{setName(e.target.value)}} placeholder="Name" />
}
const AmountInput = () => {
const [,setAmount] = useContext(Store);
return <button type="button" onClick={(e)=>{setAmount(amount+1)}}>Increase</button>
}
const App = () => {
const [name,setName] = useState("");
const [amount,setAmount] = useState(0);
return <div>
<div>{name} = {amount}</div>
<Store.Provider value={[name,setName]}><form><NameInput /></form></Store.Provider>
<Store.Provider value={[amount,setAmount]}><form><AmountInput /></form></Store.Provider>
</div>
}
This is probably a bad example to use in code, but it gets across the concept. NameInput and AmountInput are both subscribing to the same Store Context, but each of them is being given a different Provider, and so accessing totally different data sets. This particular example could lead to some confusion in the code for what the data source is, but if we extrapolate from this example we can see how the concept can help with testing frameworks.
Let's create a quick context that consumes a json file for data.
const UserContext = createContext({});
const UserProvider = ({id, children}) => {
const [user,setUser] = useState({});
useEffect(()=>{
if (id !== undefined) {
fetch(`/user/${id}`)
.then((d)=>d.json())
.then((d)=>{ setUser(d); });
}
},[id]);
return <UserContext.Provider value={[user,setUser]}>
{children}
</UserContext.Provider>
}
We only want to call this once per route, so we wrap our page at a top level with the Provider.
const App = () => {
return <UserProvider>
{/* ... */}
</UserProvider>
}
Then somewhere in our app there is a component that consumes the user data.
const Profile = () => {
const [user] = useContext(UserContext);
return <div>
{user.name}
</div>
}
Now what happens when we want to write tests for this component? We probably don't want our test to be attempting a fetch, but the Profile only gets its data from the context. This is where the previous concept comes in to play. When the test is set up, all that needs to happen is for a separate Provider to be created, and it can be given any data we want to give it. Which is the right way to do tests anyways. We don't want to check that the data is good, only that it if it is in the appropriate format the component handles the format correctly.
it('Profile shows name correctly', () => {
const {queryByText, getByText} = render(
<UserContext.Provider value={{name:'Cool Guy'}}>
<Profile />
</UserContext.Provider>
);
expect(queryByText('Cool Guy')).toBeTruthy();
});
Now when the Profile is tested, it tests only the data we give it, because, first of all it really needs a Provider somewhere in the test, and the only one it finds doesn't get its value from a fetch, it's just an object passed into it.