A better useReducer: useHyperState

zaceno

Zacharias Enochsson

Posted on March 8, 2023

A better useReducer: useHyperState

I'd like to introduce you to useHyperstate – a custom react hook for state management. Simply put, it's a better useReducer.

Being dissatisfied with React's built in hooks for state management – and being a huge fan of Hyperapp – I took Hyperapp's state management code and packaged it as a hook.

"But I've never even heard of Hyperapp!", I hear you say, "... so why should I care about useHyperState?". Well, friend, that's what I'm here to explain. I've taken a just-complex-enough app and implemented it four different ways:

  1. PlainApp.js uses the basic approach with useState and useEffect
  2. ReducerApp.js replaces useState with useReducer, but can't quite shake off the need for useEffect.
  3. HyperApp.js replaces useReducer with useHyperState (and finally eliminates useEffect)
  4. SubApp.js illustrates how we leverage the subscriptions-feature for an even more elegant solution.

Looking at each one in turn, I aim to demonstrate how it improves incrementally on the ones before.

The example we'll be looking at is a countdown timer with adjustable duration and start/stop buttons.

screenshot of example app
(The runnable example is on codesandbox here)

The Plain Solution

We use useState to keep track of the duration that the timer should run, as well as the timestamp when the timer was started (in started) and the curren timestamp (in now). With those two and the duration state variable, we can calculate remaining.

When the timer starts, we use setInterval to start getting regular updates on the current time. With useEffect we check the remaining time, so that when the time is up, we clearInterval() to stop getting updates. This requires us to also keep the interval reference (timerInterval) in a state variable.

All told, it looks like this:

function App() {
  const [duration, setDuration] = useState(5000)
  const [started, setStarted] = useState(0)
  const [now, setNow] = useState(0)
  const [timerInterval, setTimerInterval] = useState(null)

  const remaining = !!started ? duration + started - now : 0

  const handleStop = () => {
    if (timerInterval) {
      clearInterval(timerInterval)
    }
    setStarted(0)
    setNow(0)
  }

  useEffect(() => {
    if (remaining <= 0) {
      handleStop()
    }
  }, [remaining])

  const handleStart = (now) => {
    setStarted(now)
    setNow(now)
    setTimerInterval(
      setInterval(() => {
        setNow(performance.now())
      }),
      50
    )
  }

  const handleInputDuration = (value) => {
    setDuration(value * 1000)
  }

  return (
    <div className="App">
      <TimerDemo
        duration={duration}
        remaining={remaining}
        onStart={handleStart}
        onStop={handleStop}
        onDurationInput={handleInputDuration}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The plain Solution on codesandbox.io

This is pretty compact, but there is a lot going on in the component function scope that will be redefined every render.

If the logic were more complex, it would be hard to follow. Testing the logic requires testing the entire component (in a mock-DOM environment) as well as mocking setInterval - even though it's just the logic we're concerned about.

The Solution with useReducer

With useReducer, logic is easier to understand and more robust since the reducer clearly outlines the limited number of ways the state can change - the actions. It is a pure function and can be lifted out of the component scope.

But because the reducer must be pure, we need to put side effects somewhere else. No problem - we just define a function for each action, which both dispatches the action and runs associated side effects.

const initialState = {
  duration: 5000,
  started: 0,
  now: 0,
  timerInterval: null,
}

const reducer = (state, action) => {
  switch (action.type) {
    case "SET_DURATION":
      return { ...state, duration: action.duration }
    case "SET_NOW":
      return { ...state, now: action.now }
    case "START":
      return {
        ...state,
        started: action.now,
        now: action.now,
        timerInterval: action.timerInterval,
      }
    case "STOP":
      return { ...state, now: 0, started: 0, timerInterval: null }
    default:
      return state
  }
}

const calcRemaining = ({ started, duration, now }) =>
  !!started ? duration + started - now : 0

const stop = (dispatch, timerInterval) => {
  if (timerInterval) {
    clearInterval(timerInterval)
  }
  dispatch({ type: "STOP" })
}

const setDuration = (dispatch, value) => {
  dispatch({ type: "SET_DURATION", duration: value * 1000 })
}

const updateNow = (dispatch, now) => {
  dispatch({
    type: "SET_NOW",
    now: now,
  })
}

const start = (dispatch, now) => {
  const timerInterval = setInterval(
    () => updateNow(dispatch, performance.now()),
    50
  )
  dispatch({ type: "START", now, timerInterval })
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState)
  const remaining = calcRemaining(state)

  useEffect(() => {
    if (remaining <= 0) handleStop()
  }, [remaining])

  const handleStart = (now) => start(dispatch, now)
  const handleStop = () => stop(dispatch, state.timerInterval)
  const handleInputDuration = (value) => setDuration(dispatch, value)

  return (
    <div className="App">
      <TimerDemo
        duration={state.duration}
        remaining={remaining}
        onStart={handleStart}
        onStop={handleStop}
        onDurationInput={handleInputDuration}
      />
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

the useReducer solution on codesandbox.io

It is a lot less compact now, though. And we still need one useEffect in the component scope since it requires access to the state. But mainly: the testing story hasn't improved at all.

The Solution with useHyperState

With userHyperState there is no reducer. Instead each action is its own reducer in a sense. An action is a function that takes the current state (+ possible payload) and returns the new state.

Or, an action may return an array [newState, effect1, effect2, ...] where each effect is a function or a [function, payload] tuple that will be executed besides the state being updated.

A third return type is simply another action, which will be dispatched instead. In the solution below we can use that for the updateNow action - if the time is up we return the stop action instead of a new state.



//this is reusable library code

const startIntervalEffect = (dispatch, opts) =>
  dispatch(
    opts.setInterval,
    setInterval(() => dispatch(opts.onTick, performance.now()), opts.interval)
  )

const stopIntervalEffect = (_, timerInterval) => clearInterval(timerInterval)

// app-specific logic modelled here
const calcRemaining = ({ started, duration, now }) =>
  !!started ? duration + started - now : 0

const stop = (state) => [
  { ...state, started: 0, now: 0, intervalTimer: null },
  [stopIntervalEffect, state.timerInterval],
]

const setDuration = (state, value) => ({ ...state, duration: value * 1000 })

const updateNow = (state, now) => {
  const next = { ...state, now }
  return calcRemaining(next) <= 0 ? stop : next
}

const setTimerInterval = (state, timerInterval) => ({ ...state, timerInterval })

const start = (state, now) => [
  {
    ...state,
    started: now,
    now: now,
  },
  [
    startIntervalEffect,
    { setInterval: setTimerInterval, onTick: updateNow, interval: 50 },
  ],
]

const init = {
  duration: 5000,
  started: 0,
  now: 0,
  timerInterval: null,
}

function App() {
  const [state, _] = useHyperState({ init })
  return (
    <div className="App">
      <TimerDemo
        duration={state.duration}
        remaining={calcRemaining(state)}
        onStart={_(start)}
        onStop={_(stop)}
        onDurationInput={_(setDuration)}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

A useHyperState solution on codesandbox.io

Wins:

  • Easy to grasp, self-describing logic.
  • ...entirely outside of the component scope.
  • set/clear interval encapsulated in generic reusable effects that can be tested & shipped separately
  • all the rest 100% pure and dead easy to test.

But wait - there's more!

The only reason we keep intervalTimer in the state is so we will be able to stop the timer later. Not for rendering anything. This is the perfect situation to reach for the subscriptions feature.

The useHyperState solution with subscriptions


//this is reusable library code
const interval = (dispatch, opts) => {
  const handler = () => dispatch(opts.onTick, performance.now())
  const interval = setInterval(handler, opts.tick)
  return () => clearInterval(interval)
}

// app-specific logic modelled here

const calcRemaining = ({ started, duration, now }) =>
  !!started ? duration + started - now : 0

const stop = (state) => ({ ...state, started: 0, now: 0 })

const setDuration = (state, value) => ({ ...state, duration: value * 1000 })

const updateNow = (state, now) => {
  const next = { ...state, now }
  return calcRemaining(next) <= 0 ? stop : next
}

const start = (state, now) => ({
  ...state,
  started: now,
  now: now,
})

const init = {
  duration: 5000,
  started: 0,
  now: 0,
}

const subscriptions = (state) => [
  state.started > 0 && [
    interval,
    {
      onTick: updateNow,
      tick: 50,
    },
  ],
]

function App() {
  const [state, _] = useHyperState({ init, subscriptions })
  return (
    <div className="App">
      <TimerDemo
        duration={state.duration}
        remaining={calcRemaining(state)}
        onStart={_(start)}
        onStop={_(stop)}
        onDurationInput={_(setDuration)}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

useHyperState with subscriptions on codesandbox.io

All the same benefits as before, but now it's about as compact as the original solution again.

In conclusion:

useHyperState has roughly the same use case & benefits as useReducer, but has a stronger story in terms of side effects and testing. Visit https://github.com/zaceno/usehyperstate for more info.

Kudos & thanks to @jorgebucaran and the Hyperapp community for developing this state-management pattern. If you like this, you may also enjoy an incredibly tiny, fast and simple frontend framework. Visit http://hyperapp.dev

💖 💪 🙅 🚩
zaceno
Zacharias Enochsson

Posted on March 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related