Andrew
Andrew

Reputation: 795

How to type custom hook useStateWithCallback React TypeScript

I'm having problem to type the following custom React hook, I'm new to TypeScript and this is causing some confusion.

const useStateCallback = (initialState: any) => {
  const [state, setState] = useReducer<Reducer<any, any>>((state, newState) => ({ ...state, ...newState }), initialState)
  const cbRef = useRef(null)

  const setStateCallback = (state, cb) => {
    cbRef.current = cb
    setState(state)
  }

  useEffect(() => {
    if (cbRef.current) {
      cbRef.current(state)
      cbRef.current = null
    }
  }, [state])

  return [state, setStateCallback]
}

Should I use any here, if so how do I use any properly? Since this is universal function and can be used anywhere, how do I type it correctly?

I added some of my tryings right inside my example, and, as you can see I stop, because from my side of view it'll ends up with nothing but any types.

Upvotes: 3

Views: 3856

Answers (1)

Alex Wayne
Alex Wayne

Reputation: 187262

First, you'll need to make this useStateCallback accept a generic parameter that represents your state. You're going to use that parameter a lot. We'll call that S for state.

function useStateCallback<S>(initialState: S) { ... }

Next up is the reducer. It looks like you want just a single action that accepts a Partial of S that gets merged into the state. So for the two generic parameters in Reducer we use S for the state and Partial<S> for the action.

const [state, setState] = useReducer<Reducer<S, Partial<S>>>(
  (state, newState) => ({ ...state, ...newState }),
  // state is implicitly typed as: S
  // newState is implicitly typed as: Partial<S>

  initialState
)

Or you could type the arguments of the reducer function, and those types would be inferred, which looks a bit cleaner, IMHO.

const [state, setState] = useReducer(
  (state: S, newState: Partial<S>) => ({ ...state, ...newState }),
  initialState
)

For creating the ref, we need to give it a type of the callback function, unioned with null since it may not always contain a value:

const cbRef = useRef<((state: S) => void) | null>(null)

for setStateCallback, we need to accept a Partial<S> to merge with the full state, and a callback that has the full state as it's only argument:

function setStateCallback(state: Partial<S>, cb: (state: S) => void) {
  cbRef.current = cb
  setState(state)
}

Your effect should be good as is.

Last thing to do would be to change your return to:

return [state, setStateCallback] as const

This is required because typescript sees this as an array by default, but you want it to be a tuple. Instead of an array of (S | Callback)[] you want it be a tuple with exactly two elements of type [S, Callback]. Appending as const to the array tells typescript treat the array as a constant and lock those types into the proper positions.

Putting all that together, you get:

import React, { useReducer, useRef, useEffect, Reducer } from 'react'

function useStateCallback<S>(initialState: S) {
  const [state, setState] = useReducer<Reducer<S, Partial<S>>>(
    (state, newState) => ({ ...state, ...newState }),
    initialState
  )
  const cbRef = useRef<((state: S) => void) | null>(null)

  function setStateCallback(state: Partial<S>, cb: (state: S) => void) {
    cbRef.current = cb
    setState(state)
  }

  useEffect(() => {
    if (cbRef.current) {
      cbRef.current(state)
      cbRef.current = null
    }
  }, [state])

  return [state, setStateCallback] as const
}

// Type safe usage
function Component() {
  const [state, setStateCallback] = useStateCallback({ foo: 'bar' })

  console.log(state.foo)

  setStateCallback({ foo: 'baz' }, newState => {
    console.log(newState.foo)
  })

  return <div>{state.foo}</div>
}

Playground

Upvotes: 4

Related Questions