Keep your state setters close
- Context
- React
- Pattern
- Colocation
I'm sure you've seen something like this before:
This is an example of poor data encapsulation. Here we can see parent context state setters are directly exposed to children.
import { useInputContextValue } from "./InputContext";
export function InputOptionExample() {
function handleSelectValue(value) {
// accessing state setters from parent directly
const { setValue, setHasFocus, setIsTouched } = useInputContextValue();
// some conditions to be debugged later;
// for example when product team changes the idea
if (!value) {
setHasFocus(true);
setIsTouched(false);
}
if (value === "madness") {
setValue("sad");
setIsTouched(false);
return;
}
setValue(value);
}
}
Why is it wrong?
It means that the code which updates the state will be scattered all over the place, thus hard to follow and debug. This will make us suffer in the long run 🤕.
The solution
Create handlers for your parent component state related updates and make them available under a dedicated property. DO NOT expose state setters directly.
The code
Let's create context according to Kent C. Dodd's approach. Notice that i have deliberately omitted state setters in context type declaration. The handlers are created in the body of InputContextProvider; thus are close to related state setters.
import React, { createContext, useState, type PropsWithChildren } from "react";
export type InputContextType = {
// omit state setters in the declaration
value: string;
hasFocus: boolean;
isTouched: boolean;
handlers: {
handleSelectValue: (newValue: string) => void;
handleFocus: () => void;
}
}
const InputContext = createContext(undefined);
export function InputContextProvider({ children }: PropsWithChildren) {
const [value, setValue] = useState("")
const [hasFocus, setHasFocus] = useState(false)
const [isTouched, setIsTouched] = useState(false)
function handleSelectValue(newValue: string) {
setValue(newValue);
setHasFocus(false);
setIsTouched(true)
}
function handleFocus() {
// we might want to make an additional data request on focus
// or any other type of action
setHasFocus(true)
}
const contextValue: InputContextType = {
value,
isTouched,
hasFocus,
handlers: {
handleFocus,
handleSelectValue
}
}
return
{children}
}
export function useInputContextValue() {
const context = React.useContext(InputContext)
if (context === undefined) {
throw new Error('useInputContextValue must be used within a CountProvider')
}
return context
}
Now let's revisit the first example
We can use the handlers in our consumer component.
import type { ChangeEventHandler } from "react";
import { useInputContextValue } from "./InputContext";
import React from "react";
export function InputOptionExample() {
const {handlers, value} = useInputContextValue();
const onChange: ChangeEventHandler = (e) => {
// we do not care about handler logic in child
// we just use it
handlers.handleSelectValue(e.target.value);
}
return
}
The Conclusion
Our code is easier to follow and maintain.
I'm aware that the first example is a bit of an exaggeration, though i found it appropriate to highlight what problem we are trying to solve.
Thanks for your time, and see you next time.