Blog Hero Background

One Context, Two Values, With Type Safety


User Avatar
Brad WestfallSay hi on Twitter
March 28, 2024
ReactContextTypeScript

Upcoming Workshops

Last week I was helping a friend with their conference website. It had a special need that I solved with React context and I thought the solution was pretty cool and unique compared to normal needs for context, so I thought I would share it incase you can use the idea.

His site has two conference events hosted in different sub folders, and soon it will have more. Each event has various details such as links for buying tickets, their twitter accounts, and other URL resources. These resources will be different on each event and is used numerous times on several pages. This is a good use-case for context so we can define the variables once and use different sets of values for each event. Alternatively, we could just define these variables in separate config files and import them into each event, but that wouldn't give us the option for state if we need it. Aas they say, understand you tradeoffs.

For the purpose of this blog post, I use the terms "event" and "conference" somewhat interchangeably. I'm not referring to DOM events. 😉

Here's a small example of the links each event might have, but agin keep in mind each event needs its own distinct object with possibly different properties from other events:

const context = {
BUY_TICKETS_URL: 'https://...',
TWITTER_URL: 'https://...',
YOUTUBE_URL: 'https://...',
}
return (
<EventContext.Provider value={context}>
<EventPage />
</EventContext.Provider>
)

Ordinarily you would tell TypeScript up front the "type" that you plan to use for your context like this:

type ContextType = {
BUY_TICKETS_URL: string
TWITTER_URL: string
YOUTUBE_URL: string
}
const EventContext = React.createContext<ContextType>(null!)

If you need to learn more about how TypeScript works with React Context, read this post from Chance Strickland.

The problem I faced was with the ContextType. There wasn't a common "shape" for these objects for each of the two conferences. I could just use any 😈 but then I wouldn't get type safety.

Now does the title makes more sense? One Context, Two Values, With Type Safety. Normally we have one shape of data we pass down through context so the type definition is easy.

NextJS

First, the app is using Next and as you might now, I have to define my pages via filenames. Since I want to use different context values for each conference, I made this ChooseProvider at the root that identifies what pathname so we can implement different context values:

const EventContext = React.createContext<any>(null!)
function ChooseProvider({ pathname, page }) {
switch (true) {
case pathname.startsWith('/conference-one'): {
return <EventContext.Provider value={{}}>{page}</EventContext.Provider>
}
case pathname.startsWith('/conference-two'): {
return <EventContext.Provider value={{}}>{page}</EventContext.Provider>
}
}
}

Doing these types of things is sometimes a "necessary evil" with file-based routing in Next. We all know we have to do weird things with Next at times, we're all not proud of it 😞. Also I know about Next's official ways of doing sub-layouts which would also kinda work for providers, but it's not great. I would have to implement provider rules on every conference sub-page that way. (again, tradeoffs).

As for the any generic with React.createContext<any>(null!), I'll address that later.

Context values as function responses

The next part is important to getting type safety: Define each context value as the return of a function.

function getEventOneContext() {
return {
BUY_TICKETS_URL: 'https://...',
specialEventOneStuff: '...',
} as const
}
function getEventTwoContext() {
return {
BUY_TICKETS_URL: 'https://...',
specialEventTwoStuff: '...',
} as const
}

As functions, we'll be able to infer context types in custom Hooks for consuming context:

export function useEventOne() {
type ContextType = ReturnType<typeof getEventOneContext>
const context = useContext<ContextType>(EventContext)
if (!context) {
console.error('There is no EventContext provider')
}
return context
}

Now with each provider, we can get context values:

function ChooseProvider({ pathname, page }) {
switch (true) {
case pathname.startsWith('/conference-one'): {
return <EventContext.Provider value={getEventOneContext()}>{page}</EventContext.Provider>
}
case pathname.startsWith('/conference-two'): {
return <EventContext.Provider value={getEventTwoContext()}>{page}</EventContext.Provider>
}
}
}

If you need state as well, it can be made in the ChooseProvider component and passed into the respective context functions and then returned to the value, etc.

You might notice that anyone consuming context from the event pages will call Hooks like this one:

const { BUY_TICKETS_URL, specialEventOneStuff } = useEventOne()

It has type safety because the Hook infers the context value from its respective function that creates the values. This is also much better than assertions because as the context values change, we're not asserting something that might not be true anymore.

I don't think it really matters that the context was originally defined with any. Sometimes rules are meant to be broken and it feels like this is okay to me because we really consume the context type via the custom Hooks. Hit me up if you think there's a better way.

Could we have achieved the same type safety with two different context providers, each with their own generics?

const EventOneContext = React.createContext<EventOneContextType>(null!)
const EventTwoContext = React.createContext<EventTwpContextType>(null!)

Sure this way would work too, but I was trying to plan for the possibility of many events and I liked the idea of one provider serves all. (again tradeoffs).

You probably won't need this exact strategy for your specific needs, but I hope you were able to pick up some trick that helps your code be a little better!

Here's the whole strategy one one place:

const EventContext = React.createContext<any>(null!)
function ChooseProvider({ pathname, page }) {
switch (true) {
case pathname.startsWith('/conference-one'): {
return <EventContext.Provider value={getEventOneContext()}>{page}</EventContext.Provider>
}
case pathname.startsWith('/conference-two'): {
return <EventContext.Provider value={getEventTwoContext()}>{page}</EventContext.Provider>
}
}
}
function getEventOneContext() {
return {
BUY_TICKETS_URL: 'https://...',
specialEventOneStuff: '...',
} as const
}
function getEventTwoContext() {
return {
BUY_TICKETS_URL: 'https://...',
specialEventTwoStuff: '...',
} as const
}
export function useEventOne() {
type ContextType = ReturnType<typeof getEventOneContext>
const context = useContext<ContextType>(EventContext)
if (!context) {
console.error('There is no EventContext provider')
}
return context
}
export function useEventTwo() {
type ContextType = ReturnType<typeof getEventTwoContext>
const context = useContext<ContextType>(EventContext)
if (!context) {
console.error('There is no EventContext provider')
}
return context
}

Thanks for reading


Photo by Christian Holzinger on Unsplash

Subscribe for updates on public workshops and blog posts

Don't worry, we don't send emails too often.

i love react
© 2024 ReactTraining.com