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,434