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:
-
PlainApp.js
uses the basic approach withuseState
anduseEffect
-
ReducerApp.js
replacesuseState
withuseReducer
, but can't quite shake off the need foruseEffect
. -
HyperApp.js
replacesuseReducer
withuseHyperState
(and finally eliminatesuseEffect
) -
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.
(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>
)
}
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>
)
}
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>
)
}
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>
)
}
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
Top comments (0)