A better useReducer: useHyperState
Tags: react, hooks, hyperapp, javascript
Published: 2023-03-08 23:57:58

I'd like to introduce you to [useHyperstate](https://github.com/zaceno/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](https://hyperapp.dev) – 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5ln964la78ljqx7h6j6n.png)
(The runnable example is on codesandbox [here](https://codesandbox.io/s/demo-usehyperstate-5scxov))


## 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:

```jsx
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>
  )
}
```

[The plain Solution](https://codesandbox.io/s/demo-usehyperstate-5scxov?file=/src/PlainApp.js) 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.


```jsx
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>
  )
}

```
[the useReducer solution](https://codesandbox.io/s/demo-usehyperstate-5scxov?file=/src/ReducerApp.js) 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.

```jsx


//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>
  )
}
```
[A useHyperState solution](https://codesandbox.io/s/demo-usehyperstate-5scxov?file=/src/HyperApp.js) 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

```jsx

//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>
  )
}
```
[useHyperState with subscriptions](https://codesandbox.io/s/demo-usehyperstate-5scxov?file=/src/SubApp.js) 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