1

I have a component Application that can get a theme object has a props. The theme is then stored in a context using the hook React.createContext:

import React from 'react'


export interface Theme {
  colors: string[]
  images: string[]
}

interface ThemeContextParam {
  theme: Theme
  setTheme: (newTheme: Theme) => void
}

interface Props {
  theme: Theme
}

// C O N T E X T
export const ThemeContext = React.createContext<ThemeContextParam>({} as ThemeContextParam)

// C O M P O N E N T
const Application: React.FunctionComponent<Props> = (props) => {
  const {
    theme: propsTheme,
    children
  } = props

  const [themeState, setThemeState] = React.useState<Theme>(propsTheme)

  const themeContextProviderValue = {
    theme: themeState,
    setTheme: setThemeState
  }

  return (
    <ThemeContext.Provider value={themeContextProviderValue}>
      {children}
    </ThemeContext.Provider>
  )
}

export default Application

I initialise the context calling the Application component:

// C O M P O N E N T
const Theme = (): JSX.Element => {
  return (
    <Application theme={myTheme}>
      <App />
    </Application>
  )
}

So then I can consume the context like that:

import { ThemeContext } from '../Application'

// C O M P O N E N T
const App = (): JSX.Element => {
  const { theme } = React.useContext(ThemeContext)
  ...

But now I want my Theme to be a generic so that the devs can store in the context whatever they want and not just an object {colors: string[], images: string[]}. The generic will be passed to the Application component like that:

<Application<CustomThemeType> theme={myTheme}>

So I implement the generics types in Application:

import React from 'react'

// I N T E R F A C E S
export interface Theme {
  colors: string[]
  images: string[]
}

interface ThemeContextParam<T extends Theme = Theme> {
  theme: T,
  setTheme: (newTheme: T) => void
}

interface Props<T extends Theme> {
  theme: T
}

// C O N T E X T
export const ThemeContext = React.createContext<ThemeContextParam>({} as ThemeContextParam)

// C O M P O N E N T
const Application = <T extends Theme>(props: Props<T> & { children?: React.ReactNode }): JSX.Element => {
  const {
    theme: propsTheme
    children
  } = props

  const [themeState, setThemeState] = React.useState<T>(propsTheme)

  const themeContextProviderValue = {
    theme: themeState,
    setTheme: setThemeState
  }

  return (
    <ThemeContext.Provider value={themeContextProviderValue}>
      {children}
    </ThemeContext.Provider>
  )
}

But as you can see the ThemeContext context is not handling the generic type. If I want to handle the generic I need to instantiate it in the component itself like that:

const Application = <T extends Theme>(props: Props<T> & { children?: React.ReactNode }): JSX.Element => {
  const ThemeContext = React.createContext<ThemeContextParam<T>>({} as ThemeContextParam<T>)

But in this case, I'm not able to export my ThemeContext.

So has someone an idea how I can instantiate this context using a generic type and export it?

johannchopin
  • 13,720
  • 10
  • 55
  • 101

2 Answers2

2

I didn't try it out (as you didn't provide a CodeSandbox), but couldn't you create a wrapper function that takes a generic type to initialize your context environment and return both the component and the context:

function createThemeContext<T extends Theme>() {
  // C O N T E X T
  const ThemeContext = React.createContext<ThemeContextParam<T>>({} as ThemeContextParam<T>)

  // C O M P O N E N T
  const Application = (props: Props<T> & { children?: React.ReactNode }): JSX.Element => {
     ...
  }

  return {
    ThemeContext,
    Application
  };
}

...

const { ThemeContext, Application } = createThemeContext<YourTheme>();

Some smartassing: I think having a generic context is a sign of over-abstracting and rarely provides good value. I would rather have a fixed interface to define the context state, so everyone knows what this context is expected to do.

Also, I would highly recommend to return a custom hook instead of the context itself, so the usage is clear. Something like:

function useThemeContext() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error(
      'useThemeContext must be used within a ThemeContext provider!'
    );
  }
  return context;
}

Highly recommend Kent C. Dodds' blog post for context management.

Bennett Dams
  • 6,463
  • 5
  • 25
  • 45
  • From a technical point of view, yes It will work. But I'm not convinced by the final code structure that it will bring inside of library. But thanks you a lot for your time ;) – johannchopin Apr 29 '20 at 07:48
  • 1
    Woaw man I actually reconsidered which is really similar to the way of creating the [history](https://stackoverflow.com/a/42679052/8583669) in react-router. I created a [codesandbox](https://codesandbox.io/s/hardcore-keller-7uhqx?file=/src/Application.ts) that illustrate your idea. Thanks again – johannchopin Aug 21 '20 at 09:49
1

The usage you described is technically impossible. However I do have a workaround solution to your use case.

Assume your ThemeContext is meant to be a global context and <Application /> is a singleton, would not be instantiated twice in the same app, then this workaround applies:

DO NOT make Application generic, instead you export an empty Theme interface for devs to augment.

/* your module 'johannchopin-awesome-module' */
export interface Theme {}


/* dev's consuming module 'foo' */
import { Application, ThemeContext } from 'johannchopin-awesome-module'

declare module 'johannchopin-awesome-module' {
  interface Theme {
    /* they can fill in whatever fields here */
    foo: string
  }

   // OR:

   interface Theme extends UserDefinedTheme {}
}

const context = React.useContext(ThemeContext)
context.theme.foo // <- they're able to see a 'string' here

This workaround is also used by react-redux lib. Look for DefaultRootState in the source code.

I setup a codesandbox to demonstrate the usage:

Edit distracted-sanderson-5jj8z


A little explanation on why your described usage is impossible to archive.

Logically both <Application /> and any <DownStreamConsumerComponet /> depends on ThemeContext. So they don't have a say to how should ThemeContext behave, that context is the boss. If you were to pass a generic to any party, that party should be ThemeContext itself in the first place.

However that cannot be done either, due to the way how react context works. So my workaround is basically "passing-in generic" to ThemeContext using declaration merging technique.

hackape
  • 18,643
  • 2
  • 29
  • 57
  • Thanks a lot for your time and this really interesting idea. But I don't think I'm gonna implement it since I don't use a `.d.ts` file to declare types in my TypeScript project (workflow is to declare them directly in the `.ts` file and export them). It will be possible using the `Class` notation but then the way to consume the context is less convenient. – johannchopin Apr 28 '20 at 13:30
  • I think you might get it wrong. Using `.d.ts` isn't a requirement. You can just export that empty `Theme` interface from a normal `.ts` file. – hackape Apr 29 '20 at 03:32
  • 1
    I setup a codesandbox to demonstrate. Check `index.tsx` and `App.tsx`. – hackape Apr 29 '20 at 03:47
  • Mmmh really love this approach since it's exactly what I want to achieve (apart from `declare module`). This code will be part of a library used by other developers. `Application` component is here to get a theme and make it easy to provide on all the app and store it on localStorage. Is it ok to ask to developers to declare the Theme type as you show? Or is it in practice something really tricky and weird? Since I never see this type of approach I just ask myself – johannchopin Apr 29 '20 at 07:46
  • After implement it in my project I'm a little disappointed. VSCode don't recognise the changes done to `Theme` using the module declaration. Moreover, TypeScript directly return me the error `Duplicate identifier 'Theme'` wich don't seems to be a good thing :/ – johannchopin Apr 29 '20 at 12:49
  • Yeah it’s perfectly ok to ask dev to augment your module interface. Were it not then this TS feature wouldn’t exist in the first place. For the duplicate identifier error, did you also import Theme from your module when augment it? That’s unnecessary. Try compare your code to my codesandbox demo, as you can see it works there. – hackape Apr 29 '20 at 19:42
  • If it’s ok to you, maybe setup a github repo or codesandbox, i can help check into it. – hackape Apr 29 '20 at 19:44