As someone who has been using React for +10 years, I've seen many State Management libraries come and go. Three years ago, I decided to keep things simple and only use what react offers that is: useState
, useEffect
and useRef
with a splash of useContext
when needed.
Until today, that has served me well and I didn't need anything more.
However, I started building an Animation Timeline Player for Figma and that required more complex UI state than I've encountered before. Take a look 👀👇
As you can see this editor has a few different interactions:
⌨️ Keyboard shortcuts (arrow keys + space bar + shift) 🖱️ Mouse clicking and scrubbing 📍 Buttons play/pause and reset
In the future it will also including dragging to extend the timeline and other manipulation.
The code started to look like this:
function usePlayer() {
const [playState, setPlayState] = useState<'paused' | 'running'>('paused')
const [playTime, setPlayTime] = useState(0)
const animationRef = useRef<number | null>(null)
const playStartTimeRef = useRef<number | null>(null)
// keyboard shortcuts
useEffect(() => {
const checkKeyboardShortcuts = (event: KeyboardEvent) => {
const jumpAmount = event.shiftKey ? 100 : 10
switch (event.code) {
case 'Space':
togglePlayState()
break
case 'ArrowLeft':
jumpBack(jumpAmount)
break
case 'ArrowRight':
jumpForward(jumpAmount)
break
}
}
window.addEventListener('keydown', checkKeyboardShortcuts)
return () => {
window.removeEventListener('keydown', checkKeyboardShortcuts)
}
}, [])
function jumpBack(ms: number) {
setPlayTime((prevPlayTime) => Math.max(0, prevPlayTime - ms))
}
function jumpForward(ms: number) {
setPlayTime((prevPlayTime) => prevPlayTime + ms)
}
function updatePlayTime(timestamp: number) {
if (playStartTimeRef.current === null) {
playStartTimeRef.current = timestamp
}
const progress = timestamp - playStartTimeRef.current
setPlayTime(progress)
}
function togglePlayState() {
setPlayState((currentState) =>
currentState === 'paused' ? 'running' : 'paused',
)
}
// Start or resume animation
useEffect(() => {
if (playState === 'running' && animationRef.current === null) {
animationRef.current = requestAnimationFrame(updatePlayTime)
} else {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}, [playState])
function play() {
setPlayState('running')
}
function pause() {
setPlayState('paused')
}
function seek(time: number) {
setPlayTime(time)
}
function reset() {
setPlayTime(0)
setPlayState('paused')
playStartTimeRef.current = null
}
return {
isPlaying: playState === 'running',
currentTime: playTime,
play,
pause,
togglePlayState,
seek,
reset,
}
}
Between the useState
, useRef
and useEffect
in this custom hook it was very hard to follow the logic and there were subtle bugs with the state. The issues were due to stale data usually as the state of all 4 of the variables at the top depend on each other.
Normally this isn't a problem when you have independent state and you will eventually get the correct state.
In this case, since we are using it as an animation timer
and an isPlaying
state we need to get the latest state each time. Stale state will cause us to execute the wrong logic and have bugs 🐞.
How do we manage coupled state?
You can see the inter-dependent state here:
function updatePlayTime(timestamp: number) {
if (playStartTimeRef.current === null) {
playStartTimeRef.current = timestamp
}
const progress = timestamp - playStartTimeRef.current
setPlayTime(progress)
}
function togglePlayState() {
setPlayState((currentState) =>
currentState === 'paused' ? 'running' : 'paused',
)
}
// Start or resume animation
useEffect(() => {
if (playState === 'running' && animationRef.current === null) {
animationRef.current = requestAnimationFrame(updatePlayTime)
} else {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}, [playState])
I'm trying to cheat a little and useEffect
to get the latest state and useRef
to memorize timer states. This is really hard to reason with.
Options
useReducer
If we wanted to stick with just React, the next step would be to bring useReducer
. However, I'm still recovering from PTSD from my Redux projects a few years ago, and I'd like to avoid using strings for message types and use methods instead.
zustand
Very minimal library, with a simple interface you can learn quickly. It is fully type-safe and only needs to use set
and get
which are simple to understand unlike the useEffect
, useRef
and useState
mess.
xState
Lovely visualization and allows you to think about state as a state machine. To me, this is not a natural way of thinking and it is useful for certain types of problems, but not all of them.
It also uses string which is 👎.
MobX
OOP based, lots of features and a bit of an overkill for this simple case. I've used it for a project in the past, but it took a lot of time to learn.
I might use it in the future to reign in state as the app grows.
Solution
Use zustand
to always get the latest state and have clean functions we can call from the UI. This is what it looks like after introducing Zustand:
interface PlayerState {
isPlaying: boolean
currentTime: number
animationHandle: number | null
animationStartTime: number | null
play: () => void
pause: () => void
togglePlayState: () => void
seek: (ms: number) => void
jumpBack: (ms: number) => void
jumpForward: (ms: number) => void
reset: () => void
_updatePlayTime: (timestamp: number) => void
}
const usePlayerStore = create<PlayerState>(
(set, get) => ({
isPlaying: false,
currentTime: 0,
animationHandle: null,
animationStartTime: null,
play: () => {
const { currentTime, _updatePlayTime, pause } = get()
pause()
set({
isPlaying: true,
animationHandle: requestAnimationFrame(_updatePlayTime),
animationStartTime: performance.now() - currentTime,
})
},
pause: () => {
const { animationHandle } = get()
// cancel any existing animation
if (animationHandle) cancelAnimationFrame(animationHandle)
set({
isPlaying: false,
animationHandle: null,
animationStartTime: null,
})
},
togglePlayState: () => {
const { isPlaying, play, pause } = get()
isPlaying ? pause() : play()
},
jumpBack: (ms: number) => {
const { currentTime, seek } = get()
seek(Math.max(0, currentTime - ms))
},
jumpForward: (ms: number) => {
const { currentTime, seek } = get()
seek(currentTime + ms)
},
seek: (ms: number) => {
set({ currentTime: ms })
},
reset: () => {
const { pause, seek } = get()
seek(0)
pause()
},
_updatePlayTime: (timestamp: number) => {
let {
animationStartTime,
currentTime,
seek,
_updatePlayTime,
isPlaying,
} = get()
if (!isPlaying) return
if (!animationStartTime) {
animationStartTime = timestamp - currentTime
set({ animationStartTime })
}
const progress = timestamp - animationStartTime
seek(progress)
set({ animationHandle: requestAnimationFrame(_updatePlayTime) })
},
})
)
function usePlayer() {
const {
isPlaying,
currentTime,
play,
pause,
togglePlayState,
seek,
jumpForward,
jumpBack,
reset,
} = usePlayerStore()
// keyboard shortcuts
useEffect(() => {
const checkKeyboardShortcuts = (event: KeyboardEvent) => {
const jumpAmount = event.shiftKey ? 100 : 10
switch (event.code) {
case 'Space':
togglePlayState()
break
case 'ArrowLeft':
jumpBack(jumpAmount)
break
case 'ArrowRight':
jumpForward(jumpAmount)
break
}
}
window.addEventListener('keydown', checkKeyboardShortcuts)
return () => {
window.removeEventListener('keydown', checkKeyboardShortcuts)
}
}, [])
return {
isPlaying,
currentTime,
play,
pause,
togglePlayState,
seek,
reset,
}
}
This is a lot cleaner as you can see, bulk of the complexity is handled in _updatePlayTime
with play
and pause
doing a bit of setup/cleanup.
Bonus, you can write a simple middleware
to help you with debugging. I manage to find my reset
button keeps focus and if you press space
on your keyboard, it will trigger the both a play
(hotkey we hooked up) and reset
(button click event do to focus and spacebar being hit).
Conclusion
Keep things simple. Start with useState
and take it as far as you can, it took me 3 years to hit this case. Most of your work would be simple CRUD
and won't need anything too complex, so avoid adding unncessary state management.
Hope you learned something. Love to hear your thoughts below 👇💬