reactjstypescriptreact-nativefunctional-programmingcomposition

Composing React Providers with Value props in Typescript


I'd like to avoid this "problem" of nesting dozens of providers around my app component, resulting in a sideways-mountain-looking hierarchy of provider components. I'd like use composition to compose those providers. My providers usually have values. I've found a way to compose them using javascript (without typescript) - or by using Typescript with the use of the any type - But I'd really love to find a way to compose Providers that may or may not have values in Typescript without the using the any type. I think all that's holding me back is my depth of understanding of Typescript. Here is the simplest example that I can easily contrive of a provider-composing-component written in Typescript that is trying to compose two providers which both have values (I'd like to also compose providers without values in a type-safe way, but I think the TS error I get here illustrates the root of the problem I've been struggling with):

import React from "react"
import RefreshablePlayersContext from "contexts/RefreshablePlayersContext"
import RefreshableGamesContext from "contexts/RefreshableGamesContext"
import type { RefreshablePlayersContext as RefreshablePlayersContextType } from "types/RefreshablePlayersContext"
import type { RefreshableGamesContext as RefreshableGamesContextType } from "types/RefreshableGamesContext"
import type { Player } from "types/Player"
import type { Game } from "types/Game"
import type { RefreshableRequest } from "types/RefreshableRequest"

interface ProviderWithValue<ContextType> {
  provider: React.Provider<ContextType>
  value: ContextType
}

interface ProvidersProps {
  children: React.ReactElement
}

const Providers = ({ children }: ProvidersProps): React.ReactElement => {
  const [refreshableGames, setRefreshableGames] = React.useState<
    RefreshableRequest<Game[]>
  >({
    status: "Not Started",
  })

  const [refreshablePlayers, setRefreshablePlayers] = React.useState<
    RefreshableRequest<Player[]>
  >({
    status: "Not Started",
  })

  const providersWithValues: [
    ProviderWithValue<RefreshableGamesContextType>,
    ProviderWithValue<RefreshablePlayersContextType>,
  ] = [
    {
      provider: RefreshableGamesContext.Provider,
      value: { refreshableGames, setRefreshableGames },
    },
    {
      provider: RefreshablePlayersContext.Provider,
      value: { refreshablePlayers, setRefreshablePlayers },
    },
  ]

  return providersWithValues.reduceRight((acc, CurrentProviderWithValue) => {
    return (
      // the Typescript error is on the following line; complaining about the
      // value which is passed to the `value` prop below:
      <CurrentProviderWithValue.provider value={CurrentProviderWithValue.value}>
        {acc}
      </CurrentProviderWithValue.provider>
    )
  }, children)
}

export default Providers

The Typescript error, for the line called out in the comments above, reads:

Type 'RefreshableGamesContext | RefreshablePlayersContext' is not assignable to type 'RefreshableGamesContext & RefreshablePlayersContext'. Type 'RefreshableGamesContext' is not assignable to type 'RefreshableGamesContext & RefreshablePlayersContext'. Type 'RefreshableGamesContext' is missing the following properties from type 'RefreshablePlayersContext': refreshablePlayers, setRefreshablePlayers

I've not seen Typescript's & operator before, but after looking it up, it looks like it's expecting the value of both of the ProviderAndValue<T> values to both have the attributes that each of them have; e.g. the intersection of the two possible values.

I don't understand why that is, given that I explicitly type each index of the ProviderWithValues array, when I type it:

const providersWithValues: [
    ProviderWithValue<RefreshableGamesContextType>,
    ProviderWithValue<RefreshablePlayersContextType>,
  ] = [
    {
      provider: RefreshableGamesContext.Provider,
      value: { refreshableGames, setRefreshableGames },
    },
    {
      provider: RefreshablePlayersContext.Provider,
      value: { refreshablePlayers, setRefreshablePlayers },
    },
  ]

Is there a way to keep Typescript's type safety (without using the any type) to compose these two providers, which accept values of different types?

(Bonus points if your solution also provisions for composing Providers - which do not have a value prop - in a type-safe way!)


Solution

  • Wrap each Context.Provider in a new component, defined by you, which initializes its state internally and conforms to a shared Provider interface, also defined by you, so that Typescript can reduceRight over the list of providers without needing to use the any type or other overly-complex and contrived type definitions to define the data type of each provider’s value prop, which may or may not be present, and if it is, could be the shape of any of the context’s types.

    Here's a link to my blog post, on this topic.

    import React from "react"
    import { Text, View } from "react-native"
    
    type Children = React.ReactElement | React.ReactElement[]
    type Provider = ({ children }: { children: Children }) => React.ReactElement
    
    const NameContext = React.createContext<string>("")
    const AgeContext = React.createContext<number>(0)
    const HeightInInchesContext = React.createContext<number>(0)
    
    const NameProvider: Provider = ({ children }) => (
      <NameContext.Provider value={"Nick"}>{children}</NameContext.Provider>
    )
    
    const AgeProvider: Provider = ({ children }) => (
      <AgeContext.Provider value={32}>{children}</AgeContext.Provider>
    )
    
    const HeightInInchesProvider: Provider = ({ children }) => (
      <HeightInInchesContext.Provider value={72}>
        {children}
      </HeightInInchesContext.Provider>
    )
    
    const ComposedProviders = ({
      providers,
      children,
    }: {
      providers: Provider[]
      children: React.ReactElement
    }): React.ReactElement =>
      providers.reduceRight((acc, Provider) => <Provider>{acc}</Provider>, children)
    
    const App = (): React.ReactElement => {
      return (
        <ComposedProviders
          providers={[NameProvider, AgeProvider, HeightInInchesProvider]}
        >
          <PersonalDetails />
        </ComposedProviders>
      )
    }
    
    const PersonalDetails = (): React.ReactElement => {
      const name = React.useContext(NameContext)
      const age = React.useContext(AgeContext)
      const heightInInches = React.useContext(HeightInInchesContext)
      return (
        <View style=>
          <Text>Name: {name}</Text>
          <Text>Age: {age}</Text>
          <Text>
            Height: {heightInInches}
            {'"'}
          </Text>
        </View>
      )
    }
    
    export default App