React & TypeScript: Avoid context default value
Asked Answered
E

5

68

To better learn React, TypeScript, and Context / Hooks, I'm making a simple Todo app. However, the code needed to make the context feels cumbersome.

For example, if I want to change what a Todo has, I have to change it in three places (ITodo interface, default context value, default state value). If I want to pass down something new, I have to do that in three places (TodoContext, TodoContext's default value, and value=). Is there a better way to not have to write so much code?

import React from 'react'

export interface ITodo {
    title: string,
    body?: string,
    id: number,
    completed: boolean
}

interface TodoContext {
    todos: ITodo[],
    setTodos: React.Dispatch<React.SetStateAction<ITodo[]>>
}

export const TodoContext = React.createContext<TodoContext>({
    todos: [{title: 'loading', body: 'loading', id: 0, completed: false}],
    setTodos: () => {}
})

export const TodoContextProvider: React.FC<{}> = (props) => {
    const [todos, setTodos] = React.useState<ITodo[]>([{title: 'loading', body: 'loading', id: 0, completed: false}])

    return (
        <TodoContext.Provider value={{todos, setTodos}}>
            {props.children}
        </TodoContext.Provider>
    )
}
Easel answered 20/4, 2020 at 22:35 Comment(3)
Idealy this looks good but you can use redux and store it in redux-store and make it accessable at any level with react-redux connect react-redux.js.org/api/connectSealy
Is Todo just an example scenario here for the purpose of learning about Context? Context should only be used for cross-cutting concerns across the whole application, such as a theme or locale. It shouldn't be used as a store for state. Things that need to be passed down, unchanged, through multiple levels of the Component hierarchy are good candidates for Context. Todos are a classic example of State management and should not involve Context at all.Richardricharda
I disagree. I've used context plenty for state management and it's worked very well and is maintainable.Easel
E
28

After awhile, I think I've found the best way to go about this.

import React from 'react'

export interface ITodo {
    title: string,
    body?: string,
    id: number,
    completed: boolean
}

const useValue = () => {
    const [todos, setTodos] = React.useState<ITodo[]>([])

    return {
        todos,
        setTodos
    }
}

export const TodoContext = React.createContext({} as ReturnType<typeof useValue>)

export const TodoContextProvider: React.FC<{}> = (props) => {
    return (
        <TodoContext.Provider value={useValue()}>
            {props.children}
        </TodoContext.Provider>
    )
}

This way, there is single point of change when adding something new to your context, rather than triple point of change originally. Enjoy!

Easel answered 26/5, 2021 at 18:22 Comment(2)
how is this better than @Aron's answer?Mahican
@IsaacIkusika In Aron's answer, you have to create and manage another interface that specifies the contents of the TodoContext (In his case called TodoContext). In my answer, that interface is inferred based on the return value of the useValue function.Easel
W
104

There's no way of avoiding declaring the interface and the runtime values, because TS's types disappear at runtime, so you're only left with the runtime values. You can't generate one from the other.

However if you know that you are only ever going to access the context within the TodoContextProvider component you can avoid initialising TodoContext by cheating a little bit and just telling TS that what you're passing it is fine.

const TodoContext = React.createContext<TodoContext>({} as TodoContext)

If you do always make sure to only access the context inside of TodoContextProvider where todos and setTodos are created with useState then you can safely skip initialising TodoContext inside of createContext because that initial value will never actually be accessed.

Wholesale answered 21/4, 2020 at 5:55 Comment(0)
S
91

Note from the react documentation:

The defaultValue argument is only used when a component does not have a matching Provider above it in the tree.

The way I prefer to do it is by actually specifying that the default value can be undefined

const TodoContext = React.createContext<ITodoContext | undefined>(undefined)

And then, in order to use the context, I create a hook that does the check for me:

function useTodoContext() {
  const context = useContext(TodoContext)
  if (context === undefined) {
    throw new Error("useTodoContext must be within TodoProvider")
  }

  return context
}

Why I like this approach? It is immediately giving me feedback on why my context value is undefined.

For further reference, have a look at this blog post by Kent C. Dodds

Sectionalism answered 23/2, 2021 at 10:23 Comment(4)
I hate how you have to write that useTodoContext logic for each context. I wonder if there is a way to avoid having to basically duplicate that logic for each new context you define.Megaera
@SebastianNielsenYou could just write a hook factory typed like getOptionalContextHook: (Context: any, name: string) => useOptionalContet so you can const useTodoContext = getOptionalContextHook(ToDoContext, Todo)Halfblooded
To what extent is this really giving you feedback that that is the case? It just throws if it is undefined and returns that error msg in any case right? It would still say that even if there was another reason it was undefinedAiden
I accept it is a useful hint but perhaps "context is undefined. check it is being used within its provider" may be a better msgAiden
E
28

After awhile, I think I've found the best way to go about this.

import React from 'react'

export interface ITodo {
    title: string,
    body?: string,
    id: number,
    completed: boolean
}

const useValue = () => {
    const [todos, setTodos] = React.useState<ITodo[]>([])

    return {
        todos,
        setTodos
    }
}

export const TodoContext = React.createContext({} as ReturnType<typeof useValue>)

export const TodoContextProvider: React.FC<{}> = (props) => {
    return (
        <TodoContext.Provider value={useValue()}>
            {props.children}
        </TodoContext.Provider>
    )
}

This way, there is single point of change when adding something new to your context, rather than triple point of change originally. Enjoy!

Easel answered 26/5, 2021 at 18:22 Comment(2)
how is this better than @Aron's answer?Mahican
@IsaacIkusika In Aron's answer, you have to create and manage another interface that specifies the contents of the TodoContext (In his case called TodoContext). In my answer, that interface is inferred based on the return value of the useValue function.Easel
C
5

My situation might be a little different than yours (and I realize there's already an accepted answer), but this seems to work for me for now. Modified from Aron's answer above because using that technique didn't actually work in my case.

The name of my actual context is different of course.

export const TodoContext = createContext<any>({} as any)

Cassock answered 6/10, 2021 at 18:18 Comment(4)
What about my answer didn't work for you?Wholesale
export const TodoContext = createContext({} as any) also seems to work, with the caveat that it is bypassing the actual type checkingDenature
This worked for me as well as I didn't have an interface defined in my case, so the use of <any> helped that. This is because in my case I was creating a context for user data, retrieved from Google Firebase, so I couldn't make (to the best of my knowledge?) an interface to define all the data I retrieve from this. @Wholesale maybe this explains to you why it didn't work for himPantry
@lushawn you can create an interface to represent any shape of data that you wantPartition
M
0

I think @marinvirdol 's answer is great, and would like to extend that answer with a general wrapper.

import { useContext as _useContext, createContext as _createContext } from 'react'

// Wrapper functions
export function useContext<T>(context: React.Context<T>) {
    const value = _useContext(context)
    if (value === undefined) throw new Error('context must be within provider')
    return value
}

export function createContext<T>(value: T | undefined = undefined) {
    return _createContext<T | undefined>(value)
}

// Define context like so:
const SomeContext = createContext<SomeObjectType>()

// Use like before (but make sure to import the wrappers instead of the react one)
const someValue = useContext(SomeContext); // No longer optional!
Miliary answered 10/11, 2023 at 11:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.