3

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.

7
  • Show us what you tried. Commented Jul 14, 2020 at 19:15
  • @DennisVash I added my tryings Commented Jul 14, 2020 at 19:40
  • What is the purpose of this hook? How would you use it that's different from useState? And what do you use the callback for? (React already re-renders if state changes, so you typically dont need to trigger anything manually on state change) Commented Jul 14, 2020 at 19:41
  • @Alex Wayne I use it to ensure that state is actually changed, and I can proceed. This way I can track it in external functions that does't use React Commented Jul 14, 2020 at 19:45
  • So you can proceed with what? Because that's exactly what an effect with a dependency does This useEffect(someFunction, [somethingFromState]) would run someFunction automatically anytime that somethingFromState has finished saving a change. Commented Jul 14, 2020 at 19:48

1 Answer 1

4

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

Sign up to request clarification or add additional context in comments.

3 Comments

Thank you, it helps me a lot! Allthrough TS playground is not asking for typing the function's return, my ESLint settings does. If I just apply [S, Callback] it will not work, because... what is the "Callback" and it's type (from you answer above)?
Callback was just a placeholder, but it represents type Callback = (state: S) => void. You shouldn't have to type the return value of the function as long as you return the tuple at the end with as const.
@ Alex Wayne Thank you for the clarification

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.