DEV Community

Cover image for React typed state management under 10 lines of code
Jakub Švehla
Jakub Švehla

Posted on • Edited on

React typed state management under 10 lines of code

Goal

The goal of this tutorial is to write "strong" state management with 100% type inference from the javascript code.

TLDR:

Final example of the state management is available on github

or you can find a fully working example at the end of this article.

Historical background

React introduced hooks about 2 years ago.
It changed the whole ecosystem and it shows up that we can write an application without using external
state management libraries like redux or mobx and we'll still have nice minimalist code.

We were able to do the same even before the hooks were introduced,
but the problem was that the renderProps/HOC/Classes API wasn't that nice and elegant as hooks are.

If you know that you want to use Redux and you're struggling with Typescript type inference, you can check this article

Tooling of vanilla React is still pretty strong but if you have an application
with tons of lines of code that's too complex for ordinary humans, you can
start to think about some third-party state management libraries.

Custom state management Wrapper

React context is a nice option on how to split parts of your global application logic into different
files and define a new React.createContext for each module.
Then you just import the context instance and use it in the component instance by useContext hook.
A great feature of this pattern is that you don't re-render components that are not directly connected to the state that is changed.

In pure vanilla React you can write your state management via context like this.

import React, { useState, useContext } from 'react'
const MyContext = React.createContext(null)

const LogicStateContextProvider = (props) => {
  const [logicState, setLogicState] = useState(null)

  return (
    <MyContextontext.Provider value={{ logicState, setLogicState }}>
      {...props}
    </MyContextontext.Provider>
  )
}

const Child = () => {
  const logic = useContext(MyContext)
  return <div />
}

const App = () => (
  <LogicStateContextProvider>
    <Child />
  </LogicStateContextProvider>
)
Enter fullscreen mode Exit fullscreen mode

Everything looks nice until you start to add Typescript static types.
Then you realize that you have to define a new data type for each React.createContext definition.


/* redundant unwanted line of static type */
type DefinedInterfaceForMyCContext = {
  /* redundant unwanted line of static type */
  logicState: null | string
  /* redundant unwanted line of static type */
  setLogicState: React.Dispatch<React.SetStateAction<boolean>>
  /* redundant unwanted line of static type */
}

const MyContext = React.createContext<BoringToTypesTheseCha>(
  null as any /* ts hack to omit default values */
)

const LogicStateContextProvider = (props) => {
  const [logicState, setLogicState] = useState(null as null | string)

  return (
    <MyContext.Provider value={{ logicState, setLogicState }}>
      {...props}
    </MyContext.Provider>
  )
}

/* ... */
Enter fullscreen mode Exit fullscreen mode

As you can see, each React.createContext takes a few extra lines for defining Typescript static types
which can be easily inferred directly from the raw Javascript implementation.

Above all, you can see that the whole problem with inferring comes from the JSX. It's not impossible to infer data types from it!

So we have to extract raw logic directly from the Component and put it into a custom hook named useLogicState.

const useLogicState = () => {
  const [logicState, setLogicState] = useState(null as null | string)

  return {
    logicState,
    setLogicState
  }
}

const MyContext = React.createContext<
  /* some Typescript generic magic */
  ReturnType<typeof useLogicState>
>(
  null as any /* ts hack to bypass default values */
)

const LogicStateContextProvider = (props) => {
  const value = useLogicState()

  return (
    <MyContext.Provider value={value}>
      {...props}
    </MyContext.Provider>
  )
}

const Child = () => {
  const logic = useContext(MyContext)
  return <div />
}

const App = () => (
  <LogicStateContextProvider>
    <Child />
  </LogicStateContextProvider>
)
Enter fullscreen mode Exit fullscreen mode

As you can see, decoupling logic into a custom hook enable us to infer the data type by ReturnType<typeof customHook>.


If you don't fully understand this line of TS code ReturnType<typeof useLogicState> you can check my other Typescript tutorials.


I also don't like the fact that there is a lot of redundant characters which you have to have in the code
every time you want to create new React context and it's own JSX Provider Component which we use to wrap our <App />.

So I have decided to extract and wrap all dirty code in its own function.
Thanks to that we can also move that magic Typescript generic into this function and we'll be able to infer the whole state management.

type Props = { 
  children: React.ReactNode 
}

export const genericHookContextBuilder = <T, P>(hook: () => T) => {
  const Context = React.createContext<T>(undefined as never)

  return {
    Context,
    ContextProvider: (props: Props & P) => {
      const value = hook()

      return <Context.Provider value={value}>{props.children}</Context.Provider>
    },
  }
}

Enter fullscreen mode Exit fullscreen mode

So we can wrap all this magic which is hard to read into a ten-line function.

Now the genericHookContextBuilder function takes our state hook as an argument and generates Component which will work
as an App Wrapper and Context which can be imported into useContext.

we're ready to use use it in the next example.

Full example

import React, { useState, useContext } from 'react';


type Props = {
  children: React.ReactNode
}

export const genericHookContextBuilder = <T, P>(hook: () => T) => {
  const Context = React.createContext<T>(undefined as never)

  return {
    Context,
    ContextProvider: (props: Props & P) => {
      const value = hook()

      return <Context.Provider value={value}>{props.children}</Context.Provider>
    },
  }
}

const useLogicState = () => {
  const [logicState, setLogicState] = useState(null as null | string)

  return {
    logicState,
    setLogicState
  }
}

export const {
  ContextProvider: LogicStateContextProvider,
  Context: LogicStateContext,
} = genericHookContextBuilder(useLogicState)

const Child = () => {
  const logic = useContext(LogicStateContext)
  return <div />
}

const App = () => (
  <LogicStateContextProvider>
    <Child />
  </LogicStateContextProvider>
)

Enter fullscreen mode Exit fullscreen mode

Alt Text

Alt Text

As you can see, we have written a small wrapper around native React context default verbose API.
The wrapper enhanced it with out-of-the-box Typescript type inference, which enabled us not to duplicate code and to save a lot of extra lines.

I hope that you enjoyed this article the same as me and learned something new. If yes don't forget to like this article

Top comments (2)

Collapse
 
lexlohr profile image
Alex Lohr

Funny. That's the exact same pattern I developed during my last project before reading this post. The only drawback I found is that you lose the ability to have type annotations using JSdoc.

Collapse
 
cyrfer profile image
John Grant

Does this implementation avoid the performance caveat ?

re-render all consumers every time the Provider re-renders