New Project! 🎧 Listen to any text as audiobook-quality soundTry it now

React State Management: Going beyond useState after 3 years!

August 06, 20246 min read

react state management

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

  1. 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.

  1. 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.

  1. 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 👎.

  1. 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 👇💬

© 2024 Michael Yagudaev