The two types of
David Khourshid · @davidkpiano
stately.ai
state management
Why is mutable state bad?
Shared
Shared mutable state
Accidental complexity++
Let's make a
counter example
🧮
function Counter() {
const [count, setCount] = useState(0);
return (
<section>
<output>{count}</output>
<button onClick={() => setCount(count + 1)}>Add</button>
<button onClick={() => setCount(count - 1)}>Subtract</button>
</section>
);
}
useState()
conditionals
useState()
function Counter() {
const [count, setCount] = useState(0);
return (
<section>
<output>{count}</output>
<button
onClick={() => {
if (count < 10) setCount(count + 1);
}}
>
Add
</button>
<button
onClick={() => {
if (count > 0) setCount(count - 1);
}}
>
Subtract
</button>
</section>
);
}
conditionals
useState()
callbacks
function Counter() {
const [count, setCount] = useState(0);
function increment(count) {
if (count < 10) setCount(count + 1);
}
function decrement(count) {
if (count > 0) setCount(count - 1);
}
return (
<section>
<output>{count}</output>
<input
type="number"
onBlur={(e) => {
setCount(e.target.valueAsNumber);
}}
/>
<button onClick={increment}>Add</button>
<button onClick={decrement}>Subtract</button>
</section>
);
}
conditionals
useState()
callbacks
function Counter() {
const [count, setCount] = useState(0);
function changeCount(val) {
if (val >= 0 && val <= 10) {
setCount(val);
}
}
return (
<section>
<output>{count}</output>
<input
type="number"
onBlur={(e) => {
changeCount(e.target.valueAsNumber);
}}
/>
<button
onClick={(e) => {
changeCount(count + 1);
}}
>
Add
</button>
<button
onClick={(e) => {
changeCount(count - 1);
}}
>
Subtract
</button>
</section>
);
}
useReducer()
function Counter() {
const [count, send] = useReducer((state, event) => {
let currentCount = state;
if (event.type === "inc") {
currentCount = state + 1;
}
if (event.type === "dec") {
currentCount = state - 1;
}
if (event.type === "set") {
currentCount = event.value;
}
return Math.min(Math.max(0, currentCount), 10);
}, 0);
return (
<section>
<output>{count}</output>
<input
type="number"
onBlur={(e) => {
send({ type: "set", value: e.target.valueAsNumber });
}}
/>
<button
onClick={() => {
send({ type: "inc" });
}}
>
Add
</button>
<button
onClick={() => {
send({ type: "dec" });
}}
>
Subtract
</button>
</section>
);
}
useReducer()
useContext()
const CountContext = createContext();
function CountView() {
const count = useContext(CountContext);
return (
<section>
<strong>Count: {count}</strong>
<button
onClick={() => {
// send({ type: "inc" });
}}
>
Add
</button>
<button
onClick={() => {
// send({ type: "dec" });
}}
>
Subtract
</button>
</section>
);
}
function App() {
const [count, send] = useReducer((state, event) => {
let currentCount = state;
if (event.type === "inc") {
currentCount = state + 1;
}
if (event.type === "dec") {
currentCount = state - 1;
}
if (event.type === "set") {
currentCount = event.value;
}
return Math.min(Math.max(0, currentCount), 10);
}, 0);
return (
<CountContext.Provider value={count}>
<CountView />
</CountContext.Provider>
);
}
useReducer()
useContext()
const CountContext = createContext();
function CountView() {
const [count, send] = useContext(CountContext);
return (
<section>
<strong>Count: {count}</strong>
<button
onClick={() => {
send({ type: "inc" });
}}
>
Add
</button>
<button
onClick={() => {
send({ type: "dec" });
}}
>
Subtract
</button>
</section>
);
}
export function App() {
const [count, send] = useReducer((state, event) => {
let currentCount = state;
if (event.type === "inc") {
currentCount = state + 1;
}
if (event.type === "dec") {
currentCount = state - 1;
}
if (event.type === "set") {
currentCount = event.value;
}
return Math.min(Math.max(0, currentCount), 10);
}, 0);
return (
<CountContext.Provider value={[count, send]}>
<CountView />
</CountContext.Provider>
);
}
useReducer()
useContext()
subscription
const CountContext = createContext();
function CountView() {
const countStore = useContext(CountContext);
const [count, setCount] = useState(0);
useEffect(() => {
return countStore.subscribe(setCount);
}, []);
return (
<section>
<strong>Count: {count}</strong>
<button
onClick={() => {
countStore.send({ type: "inc" });
}}
>
Add
</button>
<button
onClick={() => {
countStore.send({ type: "dec" });
}}
>
Subtract
</button>
</section>
);
}
function useCount() {
const [state, send] = useReducer((state, event) => {
let currentCount = state;
if (event.type === "inc") {
currentCount = state + 1;
}
if (event.type === "dec") {
currentCount = state - 1;
}
if (event.type === "set") {
currentCount = event.value;
}
return Math.min(Math.max(0, currentCount), 10);
}, 0);
const listeners = useRef(new Set());
useEffect(() => {
listeners.current.forEach((listener) => listener(state));
}, [state]);
return {
send,
subscribe: (listener) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
}
};
}
export function App() {
const countStore = useCount();
return (
<CountContext.Provider value={countStore}>
<CountView />
</CountContext.Provider>
);
}
useReducer()
useContext()
subscription
useSelector()
const CountContext = createContext();
function useSelector(store, selectFn) {
const [state, setState] = useState(store.getSnapshot());
useEffect(() => {
return store.subscribe((newState) => setState(selectFn(newState)));
}, []);
return state;
}
function CountView() {
const countStore = useContext(CountContext);
const count = useSelector(countStore, (count) => count);
return (
<section>
<strong>Count: {count}</strong>
<button
onClick={() => {
countStore.send({ type: "inc" });
}}
>
Add
</button>
<button
onClick={() => {
countStore.send({ type: "dec" });
}}
>
Subtract
</button>
</section>
);
}
function useCount() {
const [state, send] = useReducer((state, event) => {
let currentCount = state;
if (event.type === "inc") {
currentCount = state + 1;
}
if (event.type === "dec") {
currentCount = state - 1;
}
if (event.type === "set") {
currentCount = event.value;
}
return Math.min(Math.max(0, currentCount), 10);
}, 0);
const listeners = useRef(new Set());
useEffect(() => {
listeners.current.forEach((listener) => listener(state));
}, [state]);
return {
send,
subscribe: (listener) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
},
getSnapshot: () => state
};
}
function App() {
const countStore = useCount();
return (
<CountContext.Provider value={countStore}>
<CountView />
</CountContext.Provider>
);
}
Redux 🎉
useState-like
useReducer-like
setState(value)
dispatch(event)
Direct
Indirect
- Recoil
Direct
Indirect
import React from 'react';
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
} from 'recoil';
const textState = atom({
key: 'textState', // unique ID (with respect to other atoms/selectors)
default: '', // default value (aka initial value)
});
const charCountState = selector({
key: 'charCountState', // unique ID (with respect to other atoms/selectors)
get: ({get}) => {
const text = get(textState);
return text.length;
},
});
function App() {
return (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}
function CharacterCounter() {
return (
<div>
<TextInput />
<CharacterCount />
</div>
);
}
function TextInput() {
const [text, setText] = useRecoilState(textState);
const onChange = (event) => {
setText(event.target.value);
};
return (
<div>
<input type="text" value={text} onChange={onChange} />
<br />
Echo: {text}
</div>
);
}
function CharacterCount() {
const count = useRecoilValue(charCountState);
return <>Character Count: {count}</>;
}
- Recoil
- Valtio
Direct
Indirect
import { proxy, useSnapshot } from 'valtio'
const state = proxy({ count: 0, text: 'hello' });
// This will re-render on `state.count` change but not on `state.text` change
function Counter() {
const snap = useSnapshot(state)
return (
<div>
{snap.count}
<button onClick={() => ++state.count}>+1</button>
</div>
)
}
- Recoil
- Valtio
- MobX
Direct
Indirect
import React from "react"
import ReactDOM from "react-dom"
import { makeAutoObservable } from "mobx"
import { observer } from "mobx-react-lite"
class Timer {
secondsPassed = 0
constructor() {
makeAutoObservable(this)
}
increaseTimer() {
this.secondsPassed += 1
}
}
const myTimer = new Timer()
// A function component wrapped with `observer` will react
// to any future change in an observable it used before.
const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)
ReactDOM.render(<TimerView timer={myTimer} />, document.body)
setInterval(() => {
myTimer.increaseTimer()
}, 1000)
- Recoil
- Valtio
- MobX
- Jotai
Direct
Indirect
import { atom, useAtom } from 'jotai'
// Create your atoms and derivatives
const textAtom = atom('hello')
const uppercaseAtom = atom(
(get) => get(textAtom).toUpperCase()
)
// Use them anywhere in your app
const Input = () => {
const [text, setText] = useAtom(textAtom)
const handleChange = (e) => setText(e.target.value)
return (
<input value={text} onChange={handleChange} />
)
}
const Uppercase = () => {
const [uppercase] = useAtom(uppercaseAtom)
return (
<div>Uppercase: {uppercase}</div>
)
}
// Now you have the components
const App = () => {
return (
<>
<Input />
<Uppercase />
</>
)
}
- Recoil
- Valtio
- MobX
- Jotai
Direct
Indirect
- Redux
import { createSlice } from '@reduxjs/toolkit'
import { useSelector, useDispatch } from 'react-redux'
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
increment: (state) => {
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
incrementByAmount: (state, action) => {
state.value += action.payload
},
},
})
const { increment, decrement, incrementByAmount } = counterSlice.actions
const incrementAsync = (amount) => (dispatch) => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
export function Counter() {
const count = useSelector(selectCount)
const dispatch = useDispatch()
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
<span className={styles.value}>{count}</span>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
</div>
{/* omit additional rendering output here */}
</div>
)
}
Direct
Indirect
- Redux
- Zustand
- Recoil
- Valtio
- MobX
- Jotai
import create from 'zustand'
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation)
return <button
onClick={increasePopulation}>
one up
</button>
}
Direct
Indirect
- Redux
- Zustand
- XState
- Recoil
- Valtio
- MobX
- Jotai
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';
const countMachine = createMachine({
context: {
count: 0
},
on: {
inc: {
actions: assign({
count: (ctx) => ctx.count + 1
})
},
dec: {
actions: assign({
count: (ctx) => ctx.count - 1
})
}
}
});
function App() {
const [state, send] = useMachine(countMachine);
return <main>
<button onClick={() => send({ type: 'inc' })}>
Increment
</button>
<button onClick={() => send({ type: 'dec' })}>
Decrement
</button>
</main>
}
- Recoil
- Valtio
- MobX
- Jotai
Global-capable
- Redux
- Zustand
- XState
Local
- useState
- useReducer
- Recoil
- Valtio
- MobX
- Jotai
- XState
Multi-store
- Redux
- Zustand
Single-store
Single source of truth
is a lie
Effect management
- Redux listener middleware
- Zustand async actions
- XState actions & invoked/spawned actors
- Recoil atom effects
Effect management
(state, event) => (nextState, )
effects
(state, event) => nextState
is state management
Performance
const data = useSelector(/* ... */)
const Component = observer(() => {
return <div>{state.some.data}</div>
});
Selectors (manual)
Observers (automatic)
-
Direct vs. indirect
-
Single-store vs. multi-store
-
Effect management
-
Performance
None of this matters (directly)
💯 Correctness
🏎 Velocity
🛠 Maintenance
Bug-free
Intuitive UX
Accessible
No race conditions
Adheres to specifications
Verifiable logic
Adding features
Changing features
Removing features
Fixing bugs
Adjusting tests
Onboarding
Documentation
Understanding bug root causes
Performance
Testability
Stability at complexity scale
Logged out
Logged in
LOG IN
[correct credentials]
- Make API call to Supabase
- Initiate OAuth flow
- Ensure token is valid
- Persist token as cookie
- Redirect to logged in view
Given a user is logged out,
When the user logs in with correct credentials
Then the user should be logged in
Which state management is
best for representing flows?
The one your team is most
comfortable with
v5 alpha
interpret(machine)
interpret(fromPromise(...))
interpret(fromObservable(...))
interpret(fromReducer(...))
interpret(fromCallback(...))
v5 alpha
Actor system architecture
Sequence diagrams
+
Callbacks
Event handlers
Ad-hoc logic
Ad-hoc logic
Easy
Not simple
Reducer
EVENT
Simple
Easy enough
EVENT
EVENT
EVENT
State machine
Simpler
Not easy
EVENT
EVENT
EVENT
Statechart
Simpler at complexity scale
Definitely not easy
App logic
$100
$100
$100
$100
🛒 Cart
3
Data flow tree !== UI tree
Separation can be achieved
User interface
Send events
Read state
Store
with any state management library
Map requirements to code
1
Code in a view-agnostic way
2
Make views dumb (read, send)
3
Literally profit
4
With any state management solution
- Direct or indirect state manipulation
- Single or multi store
- Effect management
- Selectors or auto-observable
- Framework independence
- Map requirements to code
- Modify features
- Fix logic-related bugs quickly
- Explain features clearly
- Enable team to understand logic
- Documentation + diagrams
Make it work, make it right, make it fast
The two types of
state management:
Simple
Which will you choose?
& easy
Kiitos, React Finland!
The two types of state management
By David Khourshid
The two types of state management
- 1,794