Engineers often jump into Redux, MobX, XState, or Recoil before they really need to. That's what large orgs use so it must be right.
You are not Google, Facebook, Amazon, or Uber. You're shooting yourself in the foot by blindly following their patterns. Probably.
This article is based on my talk at Reactive Conf Meetup #4 in May 👆
The Wormhole State Management pattern stems from a single idea: use the least complex machinery that solves your problem.
That's why it's a pattern, not a library.
You have control to make it as simple or complex as fits the problem. You can scale it from small examples to large codebases.
It's the pattern behind libraries like useAuth and every React app – big or small – that I've built in the past 18 months. And now it has a name ✌️
Here's how it works
Keep local state local
What's the smallest amount of sharing you can get away with?
Keep your state there. As close to where it's used as possible.
If a single component cares, use that. If a few components care, share it with props. If many components care, put it in context.
Context is like a wormhole. It bends your component tree so pieces far apart can touch.
Leverage custom hooks to make this easy.
Keep state simple
What's the simplest state you can get away with?
Do that. You can always change it later.
You can always make a simple codebase complex, but you can't make a complex codebase simple.
Your code becomes easier to understand, too.
Imagine stepping into a component you haven't seen in 2 years. What happens?
First you read the component code. It kind of makes sense.
Then you travel 5 files into a different directory and learn about how its state works.
3 more files away is a bit more of its state.
Another 10 files away are all the side-effects from who knows what.
Before you know it, you've read most of the codebase and you're still not sure how this component works. You try to isolate it to run some tests and realize it needs the whole state management jungle to work.
You wanted a banana. You got a gorilla holding the banana and the entire jungle.
An example
Follow along as I build a contrived click counter. The Wormhole State Management pattern is best explained through examples ✌️
step 1
We start with useState
because it's the simplest.
const ClickCounter = () => {
const [count, setCount] = useState(0)
function onClick() {
setCount((count) => count + 1)
}
return <button onClick={onClick}>{count} +1</button>
}
count
holds the current number of clicks, setCount
lets us update the value on every click.
Simple enough.
Presentation isn't the prettiest though. Let's improve it with a custom button component and some nesting.
step 2
We created a reusable PrettyButton
that ensures every button in our app looks fabulous. Nice and pink.
State remains in the ClickCounter
component.
const ClickCounter = () => {
const [count, setCount] = useState(0)
function onClick() {
setCount((count) => count + 1)
}
return (
<>
<p>You have clicked buttons {count} times</p>
<div style={{ textAlign: "right" }}>
<PrettyButton onClick={onClick}>+1</PrettyButton>
</div>
</>
)
}
This is the least amount of state sharing necessary. We kept state simple, too.
The counter component cares about clicks and counts so it passes a callback into the button as a prop. Function gets called, state updates, component re-renders.
No complex machinery required.
step 3
What if our state is more complex? We have 2 items that belong together.
You can keep complex values in your state. Works great.
const ClickCounter = () => {
const [count, setCount] = useState({ A: 0, B: 0 })
function onClickA() {
setCount((count) => {
return { ...count, A: count.A + 1 }
})
}
function onClickB() {
setCount((count) => {
return { ...count, B: count.B + 1 }
})
}
return (
<>
<p>
You have clicked buttons A: {count.A}, B: {count.B} times
</p>
<div style={{ textAlign: "right" }}>
<PrettyButton onClick={onClickA}>A +1</PrettyButton>
<PrettyButton onClick={onClickB}>B +1</PrettyButton>
</div>
</>
)
}
We've split count
into an object – { A, B }
.
Now a single piece of state can hold multiple values. Separate counts for separate button clicks.
React uses JavaScript equality to detect changes for re-renders so you have to make copies of full state on every update. This gets slow around 10,000 or so elements.
You can use useReducer
here, too. Especially when your state becomes more complex and items often update separately.
Similar state using useReducer
would look something like this:
const [state, dispatch] = useReducer(
(action, state) => {
switch (action.type) {
case "A":
return { ...state, A: state.A + 1 }
case "B":
return { ...state, A: state.A + 1 }
}
},
{ A: 0, B: 0 }
)
function onClickA() {
dispatch({ type: "A" })
}
The more complex your state, the more this makes sense.
But I think those switch statements get messy fast and your callback functions are already actions anyway.
step 4
What if we want 2 buttons to update the same state?
You can pass both the count
and setCount
to your components as props. But this is getting messy.
const AlternativeClick = ({ count, setCount }) => {
function onClick() {
setCount((count) => {
return { ...count, B: count.B + 1 }
})
}
return (
<div style={{ textAlign: "left" }}>
You can also update B here
<br />
<PrettyButton onClick={onClick}>B +1</PrettyButton>
<p>It's {count.B} btw</p>
</div>
)
}
We've created a component that's hard to move and needs to understand too much of parent logic. Concerns are split, abstractions are weird, and we've created a mess.
You can fix it a little by passing just the parts of state it needs and a more customized setCount
. But that's a lot of work.
step 5
Instead, you can use a wormhole to share state with a custom hook. 🤩
You now have 2 independent components sharing state. Put them anywhere in your codebase and it Just Works™.
Need to access shared state somewhere else? Add the useSharedCount
hook and voila.
Here's how this part works.
We have a context provider with some machinery inside:
export const SharedCountProvider = ({ children }) => {
// replace with useReducer for more flexiblity
const [state, setState] = useState(defaultState)
const [contextValue, setContextValue] = useState({
state,
// dispatch // from your reducer
// this is where a reducer comes handy when this grows
setSharedCount: (key, val) => {
setState((state) => {
return { ...state, [key]: val }
})
},
// other stuff you need in context
})
// avoids deep re-renders
// when instances of stuff in context change
useEffect(() => {
setContextValue((currentValue) => ({
...currentValue,
state,
}))
}, [state])
return (
<SharedCountContext.Provider value={contextValue}>
{children}
</SharedCountContext.Provider>
)
}
The context provider uses a rich state
variable to keep your state. This is { A, B }
for us.
The contextValue
is a richer piece of state that also holds everything you need to manipulate that state. Often this would be a dispatch
method from your reducer, or custom state setters like we have here.
Our setSharedCount
method gets a key
and val
and updates that part of state.
// set count.B to 10
setSharedCount("B", 10)
We then have a side-effect that watches state
for changes and triggers re-renders when needed. This avoids deep re-renders every time we redefine our dispatch methods or whatever.
Makes the React tree more stable ✌️
Every component rendered within this provider can use this same custom hook to access everything it needs.
export function useSharedCount() {
const { state, setSharedCount } = useContext(SharedCountContext)
function incA() {
setSharedCount("A", state.A + 1)
}
function incB() {
setSharedCount("B", state.B + 1)
}
return { count: state, incA, incB }
}
The custom hook taps into React Context for shared state, defines simpler incA
and incB
helper methods, and returns them with state.
That means our AlternativeClick
component can look like this:
import { useSharedCount } from "./SharedCountContextProvider"
const AlternativeClick = () => {
const { count, incB } = useSharedCount()
return (
<div style={{ textAlign: "left" }}>
You can also update B here
<br />
<PrettyButton onClick={incB}>B +1</PrettyButton>
<p>It's {count.B} btw</p>
</div>
)
}
Gets count
and incB
from custom hook. Uses them.
😍
What about performance?
It's good.
Share state as little as possible. Use different context providers for different parts of your app.
Don't make it global unless it needs to be global. Wrap the smallest part of your tree that you can get away with.
Yes even though it's easier to always wrap everything.
What about complexity?
What complexity? Keep it small. Don't stuff shit in there that you don't need.
That's where the foot shooting starts. When folks put everything in global state.
Also why apps get slow 😉
I hate managing my own state, bleh
That's fair. Did you know that this same pattern works with many state management libraries?
See that part in our SharedCountProvider
that deals with state changes? This part:
const [contextValue, setContextValue] = useState({
state,
// dispatch // from your reducer
// this is where a reducer comes handy when this grows
setSharedCount: (key, val) => {
setState((state) => {
return { ...state, [key]: val }
})
},
// other stuff you need in context
})
You can use XState for that. Or a reducer. Or even Redux, if you really want to.
Altho if you're using Redux you might as well go all the way 😛
Does this really scale up and down all sorts of apps?
Yes. Try it.
Cheers,
~Swizec
Continue reading about Wormhole state management
Semantically similar articles hand-picked by GPT-4
- Swap useReducer with XState – CodeWithSwiz 13
- useReducer + useContext for easy global state without libraries
- React context without context, using XState – CodeWithSwiz 14, 15
- Delightful state management with hooks and Constate
- How to use React Context effectively
Learned something new? Want to become a React expert?
Learning from tutorials is easy. You follow some steps, learn a smol lesson, and feel like you got dis 💪
Then comes the interview, a real world problem, or a question from the boss. Your mind goes blank. Shit, how does this work again ...
Happens to everyone. Building is harder than recognizing and the real world is a mess! Nothing fits in neat little boxes you learned about in tutorials.
That's where my emails come in – lessons from experience. Building production software :)
Leave your email and get the React email Series - a series of curated essays on building with React. Borne from experience, seasoned with over 12+ years of hands-on practice building the engineering side of growing SaaS companies.
Get Curated React Essays
Get a series of curated essays on React. Lessons and insights from building software for production. No bullshit.
Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.
Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.
Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.
Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev
Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization
Want to get my best emails on JavaScript, React, Serverless, Fullstack Web, or Indie Hacking? Check out swizec.com/collections
Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog
Want to brush up on your modern JavaScript syntax? Check out my interactive cheatsheet: es6cheatsheet.com
By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️