Tabs/View Selector Provider with React & TypeScript

October 31, 2022

2 min read

Tabs/View Selector Provider with React & TypeScript
Watch on YouTube

It's a popular UI pattern to have a selector or tabs that change the content below. For simple situations, it's enough to store the view with the useState hook, but often children components need a selected view name with a function to change it, and we end up with either prop drilling or a state provider.

These providers take some code to set up, so let's write a function that will generate a boilerplate we can use to add new views and selectors. You can find both code and demo in the description.

Here's a simple example where we have two views, and we call a function that generates a provider, hook, and component for rendering the selected view. Below, we have a selector component to render view options. We place it under the ViewProvider together with the RenderView component that takes functions for every view to render the selected one.

The getViewSetup is a generic function that takes two arguments: default view and name of view group. First, we create an interface with the state and a context. The provider has a simple implementation where we store the selected view with the useState hook.

import React from "react"
import { ReactNode, createContext, useState } from "react"
import { ComponentWithChildrenProps } from "lib/shared/props"

import { createContextHook } from "./createContextHook"

export function getViewSetup<T extends string | number | symbol>(
  defaultView: T,
  name: string
) {
  interface ViewState {
    view: T
    setView: (view: T) => void
  }

  const ViewContext = createContext<ViewState | undefined>(undefined)

  const ViewProvider = ({ children }: ComponentWithChildrenProps) => {
    const [view, setView] = useState<T>(defaultView)

    return (
      <ViewContext.Provider value={{ view, setView }}>
        {children}
      </ViewContext.Provider>
    )
  }

  const useView = createContextHook(ViewContext, `${name}ViewContent`)

  const RenderView = (props: Record<T, () => ReactNode>) => {
    const { view } = useView()
    const render = props[view]

    return <>{render()}</>
  }

  return {
    ViewProvider,
    useView,
    RenderView,
  }
}

To access provider value, we leverage the createContextHook that will throw an error if the provider is missing. Finally, we make the RenderView function that receives the render function for every view in the group and picks the one based on the useState hook.

import { Context as ReactContext, useContext } from "react"

export function createContextHook<T>(
  Context: ReactContext<T | undefined>,
  contextName: string
) {
  return () => {
    const context = useContext(Context)

    if (!context) {
      throw new Error(`${contextName} is not provided`)
    }

    return context
  }
}