Skip to content

Instantly share code, notes, and snippets.

@swyxio
Last active May 4, 2023 02:15
Show Gist options
  • Save swyxio/f18fe6dd4c43fddb3a4971e80114a052 to your computer and use it in GitHub Desktop.
Save swyxio/f18fe6dd4c43fddb3a4971e80114a052 to your computer and use it in GitHub Desktop.
better createContext APIs with setters, and no default values, in Typescript. this is documented in https://github.com/typescript-cheatsheets/react-typescript-cheatsheet/blob/master/README.md#context
// create context with no upfront defaultValue
// without having to do undefined check all the time
function createCtx<A>() {
const ctx = React.createContext<A | undefined>(undefined)
function useCtx() {
const c = React.useContext(ctx)
if (!c) throw new Error("useCtx must be inside a Provider with a value")
return c
}
return [useCtx, ctx.Provider] as const
}
// usage - no need to specify value upfront!
export const [useCtx, SettingProvider] = createCtx<string>()
export function App() {
// get a value from a hook, must be in a component
const key = useLocalStorage('key')
return (
<SettingProvider value={key}>
<Component />
</SettingProvider>
)
}
export function Component() {
const key = useCtx() // can still use without null check!
return <div>{key}</div>
}
function useLocalStorage(a: string) {
return 'secretKey' + a
}
export function createCtx<StateType, ActionType>(
reducer: React.Reducer<StateType, ActionType>,
initialState: StateType,
) {
const defaultDispatch: React.Dispatch<ActionType> = () => initialState // we never actually use this
const ctx = React.createContext({
state: initialState,
dispatch: defaultDispatch, // just to mock out the dispatch type and make it not optioanl
})
function Provider(props: React.PropsWithChildren<{}>) {
const [state, dispatch] = React.useReducer<React.Reducer<StateType, ActionType>>(reducer, initialState)
return <ctx.Provider value={{ state, dispatch }} {...props} />
}
return [ctx, Provider] as const
}
// usage
const initialState = { count: 0 }
type AppState = typeof initialState
type Action =
| { type: 'increment' }
| { type: 'add'; payload: number }
| { type: 'minus'; payload: number }
| { type: 'decrement' }
function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case 'increment':
return { count: state.count + 1 }
case 'decrement':
return { count: state.count - 1 }
case 'add':
return { count: state.count + action.payload }
case 'minus':
return { count: state.count - action.payload }
default:
throw new Error()
}
}
const [ctx, CountProvider] = createCtx(reducer, initialState)
export const CountContext = ctx
// top level example usage
export function App() {
return (
<CountProvider>
<Counter />
</CountProvider>
)
}
// example usage inside a component
function Counter() {
const { state, dispatch } = React.useContext(CountContext)
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'add', payload: 5 })}>+5</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'minus', payload: 5 })}>-5</button>
</div>
)
}
export function createCtx<A>(defaultValue: A) {
type UpdateType = React.Dispatch<React.SetStateAction<typeof defaultValue>>
const defaultUpdate: UpdateType = () => defaultValue
const ctx = React.createContext({ state: defaultValue, update: defaultUpdate })
function Provider(props: React.PropsWithChildren<{}>) {
const [state, update] = React.useState(defaultValue)
return <ctx.Provider value={{ state, update }} {...props} />
}
return [ctx, Provider] as const
}
// usage
const [ctx, TextProvider] = createCtx("someText")
export const TextContext = ctx
export function App() {
return (
<TextProvider>
<Component />
</TextProvider>
)
}
export function Component() {
const { state, update } = React.useContext(ctx)
return (
<label>
{state}
<input type="text" onChange={e => update(e.target.value)} />
</label>
)
}
@swyxio
Copy link
Author

swyxio commented Aug 26, 2019

@EthanSK i have updated

export function createCtx<StateType, ActionType>(
  reducer: React.Reducer<StateType, ActionType>,
  initialState: StateType,
) {
  const defaultDispatch: React.Dispatch<ActionType> = () => initialState // we never actually use this
  const ctx = React.createContext({
    state: initialState,
    dispatch: defaultDispatch, // just to mock out the dispatch type and make it not optioanl
  })
  function Provider(props: React.PropsWithChildren<{}>) {
    const [state, dispatch] = React.useReducer<React.Reducer<StateType, ActionType>>(reducer, initialState)
    return <ctx.Provider value={{ state, dispatch }} {...props} />
  }
  return [ctx, Provider] as const
}
// usage
const initialState = { count: 0 }
type AppState = typeof initialState
type Action =
  | { type: 'increment' }
  | { type: 'add'; payload: number }
  | { type: 'minus'; payload: number }
  | { type: 'decrement' }

function reducer(state: AppState, action: Action): AppState {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 }
    case 'decrement':
      return { count: state.count - 1 }
    case 'add':
      return { count: state.count + action.payload }
    case 'minus':
      return { count: state.count - action.payload }
    default:
      throw new Error()
  }
}
const [ctx, CountProvider] = createCtx(reducer, initialState)
export const CountContext = ctx

// top level example usage
export function App() {
  return (
    <CountProvider>
      <Counter />
    </CountProvider>
  )
}

// example usage inside a component
function Counter() {
  const { state, dispatch } = React.useContext(CountContext)
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'add', payload: 5 })}>+5</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'minus', payload: 5 })}>+5</button>
    </div>
  )
}

@pandaCure
Copy link

how to use useReducer the three argument by ts

@odGit
Copy link

odGit commented Mar 19, 2020

@sw-yx how about changing createCtx into following:

type StoreApi = {
  state: typeof initialState;
  dispatch: React.Dispatch<Action>;
}

const ctx = React.createContext<StoreApi>({
  state: initialState,
  dispatch: () => {}, //because it inherits it's type from StoreApi 
})

function Provider(props: React.PropsWithChildren<{}>) {
// changed <React.Reducer<StateType, ActionType>> to <React.Reducer<AppState, Action>>
  const [state, dispatch] = React.useReducer<React.Reducer<AppState, Action>>(reducer, initialState)
  return <ctx.Provider value={{ state, dispatch }} {...props} />
}

function useCtx() {
  const context = React.useContext(ctx)
  if (context === undefined) throw new Error(`No provider for AppContext given`)
  return context
}

export {Provider, useCtx}

// example using useCtx
const {state, dispatch} = useCtx();

Are there any downsides to this approach ?

@swyxio
Copy link
Author

swyxio commented Mar 25, 2020

idk, if it works for you then good for you! :)

@zerofront
Copy link

@sw-yx Hello, How can I use data(from getServerSideProps in Next.js) as initialState ?

@swyxio
Copy link
Author

swyxio commented Jun 16, 2020

idk, what have you tried so far? can u make a small repro?

@us-22
Copy link

us-22 commented Sep 22, 2021

@zerofront I made a minor change to make it work. you can pass defaultState as a prop to provider:

import React from 'react';

export function createCtx<StateType, ActionType>(
    reducer: React.Reducer<StateType, ActionType>,
    initialState: StateType,
  ) {
    const defaultDispatch: React.Dispatch<ActionType> = () => initialState // we never actually use this
    const ctx = React.createContext({
      state: initialState,
      dispatch: defaultDispatch, // just to mock out the dispatch type and make it not optioanl
    })
    function Provider(props: React.PropsWithChildren<{defaultState?: StateType}>) {
      const stt = (props.defaultState ? props.defaultState : initialState)
      const [state, dispatch] = React.useReducer<React.Reducer<StateType, ActionType>>(reducer, stt)
      return <ctx.Provider value={{ state, dispatch }} {...props} />
    }
    return [ctx, Provider] as const
  }


/// usage

export function App() {
  return (
    <SomeProvider defaultState={defaultState}>
      <Counter />
    </CountProvider>
  )
}

@simpleneeraj
Copy link

Based on these concept i developed a npm package mini-state
You can use this by installing npm install mini-state and use this like below,

Initialize global state

counter.tsx

import { createStateCTX } from "mini-state";
// Create Global State like useState
const [Context, Provider] = createStateCTX(0);

export { Context, Provider };

Adding provider in root of our app

index.tsx

import { StrictMode } from "react";
import {createRoot} from "react-dom/client";
import App from "./App";
import { Provider } from "context/counter";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <Provider>
      <App />
    </Provider>
  </StrictMode>
);

Usage in app with a counter example

app.tsx

import { Context } from "context/counter";
import { useContext } from "react";

function App() {
  const { state, dispatch } = useContext(Context);

  const add = () => {
    dispatch((i) => i + 1);
  };
  const remove =  () => {
    dispatch((i) => i - 1);
  };
  return (
    <div className="App">
      <h1>Counter {state}</h1>
      <button onClick={add}>Increase</button>
      <button onClick={remove}>Increase</button>
    </div>
  );
}

export default App;

Congratulations 🎉 We successfully added this to your application

Live Example on Codesandbox

@swyxio
Copy link
Author

swyxio commented Jul 30, 2022

thanks!!! tweeted it out!

@thomasnal
Copy link

Hi, I use 'createContext-useReducer', this gist has been very helpful. But I have an issue that eslint does not accept the dispatch function as stable and litters the screen with warnings. Considering your previous example, to explain the issue I extend your example,

// ... copy of createCtx-useReducer gist
// example usage inside a component
function Counter() {
  const { state, dispatch } = React.useContext(CountContext);
  useEffect(() => {
    dispatch({ type: 'operation-to-calculate-after-render' }); // React Hook useEffect has missing dependencies: 'dispatch'
  }, []);
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'add', payload: 5 })}>+5</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'minus', payload: 5 })}>+5</button>
    </div>
  )
}

How to convince eslint that the dispatch does not need to be in the dependencies? If we add dispatch to the dependencies then useEfffect goes to infinite loop.

The dispatch function originates from the useReducer, however, since it is retrieved via the useContext, from the Provider, it is not treated correctly in the useEffect. I see no solution provided by ReactJS for this situation and that they left developers with an incomplete system.

Your example above reduced to a brief complete example below,

const initState = { a: 1 };
type Action = { type: 'inc' };
const defaultDispatch: React.Dispatch<Action> = () => initState;

const ctx = React.createContext({
  state: initState,
  dispatch: defaultDispatch
});

function reducer(action, state)
{
  if (!action) return;
  return state;
}

function App(props: React.PropsWithChildren)
{
  const { state, dispatch } = React.useReducer(reducer, initState);
  return (
    <ctx.Provider value={{ state, dispatch }} {...props}>
      <Component />
    </ctx.Provider>
  );
}

function Component()
{
  const { state, dispatch } = React.useContext(ctx);
  React.useEffect(() => {
    dispatch({ type: 'inc' }); // React Hook useEffect has missing dependencies: 'dispatch'
  }, []);

  return <div>{state.a}</div>;
}

The sample may not look useful, why calling dispatch in useEffect, more complex sample can be:

  React.useEffect(() => {
    if (!aState) return;
    // Call dispatch only after a certain complex state has been reach by the app user.
    dispatch({ type: 'inc' }); // React Hook useEffect has missing dependencies: 'dispatch'
  }, [aState]);

@ronaldruzicka
Copy link

ronaldruzicka commented Sep 18, 2022

I had unnecessary re-renders because of plain object passed into the provider value. But wrapping the value in a useMemo hook solved our problem. We found out, that when we had a state change in the provider, it re-rendered. So the value object was new on each render. Which caused a change in all of the consumers, which caused re-renders.

Maybe it could be the same issue? If not, it will definitely solve at least some of unnecessary re-renders that you could have.

function App(props: React.PropsWithChildren) {
  const { state, dispatch } = React.useReducer(reducer, initState);

  const providerValue = useMemo(() => ({ state, dispatch }), [state, dispatch])

  return (
    <ctx.Provider value={providerValue} {...props}>
      <Component />
    </ctx.Provider>
  );
}

@apo1798
Copy link

apo1798 commented Jan 2, 2023

Hi sw-yx, thanks for sharing this three sample useful code snippet for useContext in TypeScript. Just found a tiny typo in the reducer code in line 60

<button onClick={() => dispatch({ type: 'minus', payload: 5 })}>+5</button> // should be `-5`

Wishing you a great day and happy New Year ^_^

@swyxio
Copy link
Author

swyxio commented Jan 12, 2023

thanks! updating it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment