The limits of type safety in React Context

November 15, 2024

In a virtual presentation I attended about the compound component pattern, I made a comment about some of this pattern’s tradeoffs. Because the pattern relies on React context (a form of dependency injection in the React tree) it inherits its limitations:

  • Orchestrating state from a top component is not always simple. In some cases, it is simpler to pass props down, which is very explicit. TypeScript can help group props into single objects and maintain the safety (no missing props when needed).
  • TypeScript doesn’t understand the concept of the React tree. TypeScript can’t statistically verify that you are using your nested components under the right parent. It can’t verify that there is a certain ancestor, nor whether there are other components between one and the other, which many compound components depend on.

This last point proved to be difficult to convey as we ran out of time, so I promised to share an example to illustrate the problem. Hence this document.

The simplest version of the problem

Let’s build a context for a Person, and use it to render a PersonCard.

tsx
const personContext = createContext<Person>(new Person())
 
const PersonCard = () => {
const person = useContext(personContext)
const person: Person
return <p>{person.getFullname()}</p>
}

This is type-safe in that person will always be an instance of the Person class. However, note that this is only because we have provided a default value for the personContext. TypeScript doesn’t know if the context has been provided or not:

tsx
// Because, did we do this?
createRoot(document.getElementById('root')!).render(<PersonCard />)
// ...or this?
createRoot(document.getElementById('root')!).render((
<personContext.Provider value={somePerson}>
<PersonCard />
</personContext.Provider>
))
// TypeScript doesn't know.

The workaround for this type limitation is the default value. But what if a default doesn’t make sense? There is no such thing as a default person. Well, that's what null is for.

tsx
const personContext = createContext<Person | null>(null)
 
const PersonCard = () => {
const person = useContext(personContext)
const person: Person | null
// TypeScript doesn't know if a person is provided. We have to check.
if (!person) throw new Error('No Person provided')
return <p>{person.getFullname()}</p>
}

Now, regardless of whether the value has been provided or not, we will be safe at the type level… at the cost of runtime safety. It is still very possible to use the PersonCard in a location in the React tree that doesn’t have a provided personContext:

tsx
createRoot(document.getElementById('root')!).render(<PersonCard />)

In which case, the “No Person provided” error will be thrown. TypeScript can’t statically protect against this. It isn’t just that TypeScript doesn’t know whether a value was provided. TypeScript doesn’t know it doesn’t know, because it has no understanding of the React tree.

The situation is akin to not being able to trust the signature of a function inside its body:

tsx
const calculateAge = (person: Person) => {
// what if `person` is not a Person?
// What if it's undefined, null, or something else entirely?
}

If we had this issue with normal functions, TypeScript would not be useful. This is why TypeScript also tends to become more strict, discourage anys, etc.: they defeat the purpose of using TypeScript in a codebase.

Yet, this is what we encounter with useContext. This is really the gist of the issue. Everything else in this document derives from this problem, and shows how it affects compound components, and how you can try hard to cover up for this lack of type safety, making things reasonably safe for many use-cases, while still acknowledging that, ultimately, using React context requires giving up on type safety for convenience.

Still, this doesn’t mean that React context shouldn’t be used. It can be useful and a reasonable tool. But it’s important to understand its tradeoffs and the options to mitigate its cons.

A simple compound component

Consider now a scenario with a Select component built using the compound component pattern. You would use it like this:

tsx
<Select onChange={handleChange} value={currentServiceType}>
{Object.values(ServiceType).map(serviceType => (
<Option key={serviceType} value={serviceType}>{serviceType}</Option>
))}
</Select>

If you were to build this component, how would you ensure that the options are valid for the type that you expect in the Select component? Or, looking at it from another angle, how would you make the type of the value and onChange props of the component match the options? How would you make the following impossible (note the last option)?

tsx
enum ServiceType {
Water = 'Water',
Electricity = 'Electricity',
Gas = 'Gas'
}
 
const [serviceType, setServiceType] = useState(ServiceType.Water);
 
<Select onChange={setServiceType} value={serviceType}>
{Object.values(ServiceType).map(serviceType => (
<Option key={serviceType} value={serviceType}>{serviceType}</Option>
))}
// Note this is not a valid ServiceType
<Option value="Custom">Custom</Option>
</Select>

Perhaps this is a good exercise to the reader. If not sure, we can move on and consider a more complex scenario.

A form library

Let’s build a form library for React. This may be too complex an example, but this is a case I’ve had to deal with, and coming up with creative, comprehensive, but simple examples is harder than it seems. And, while it’s true that form libraries don’t necessarily rely on compound components—and even when they do, it may not feel like that—, they run into the same fundamental issue that I’m trying to illustrate. This time, it manifests as the inability to safely use a generic context.

In an ideal world, our form library would allow developers to declare a form definition at the top level, describing how to validate its values, and other configuration values. For validation, it would be great to be able to pass a schema that, additionally, describes the form's shape at the type level, so that we can provide safety in the form fields. Zod is a popular choice for this. I will use Schema in this example because it is a somewhat unknown gem I enjoy using.

Our form library will also need some components that communicate with the form state, to read and to set the field values. Then you can have components that receive a name indicating the path in the schema that they control. Because TypeScript will know about the type of the schema, we would hope to be able to figure out what names are valid, and the value type for each name—thus preventing developers from rendering invalid inputs. Wouldn’t that be great?

tsx
import React from 'react';
import { Schema } from 'effect'
import { Form, TextInput, DateInput } from './OurSuperCoolFormLibrary'
 
const PersonSchema = Schema.Struct({
name: Schema.String,
dob: Schema.Date,
})
 
const PersonForm = () => (
<Form schema={PersonSchema}>
<TextInput name="name" label="Name" />
<DateInput name="dob" label="Date of birth" />
</Form>
)

What would the implementation of our Form component look like? Something like the following, to start simple.

tsx
class FormState<T> {
public readonly schema: Schema.Schema<T>;
/* the current state of the form object */
private value: T;
 
constructor(schema: Schema.Schema<T>, initialValue: T) {
this.schema = schema;
this.value = initialValue;
}
}
 
const formContext = createContext<
// formContext has to support any type for FormState.
| FormState<any>
| null
>(null);
 
const useForm = <T extends unknown>(): FormState<T> => {
// Pinky-promise that the FormState will hold T
const formState = useContext<FormState<T> | null>(formContext);
if (!formState) {
throw new Error('Form context used outside of a <Form>');
}
return formState;
};
 
const Form = <T extends unknown>({
schema,
initialValue,
children,
}: {
schema: Schema.Schema<T>;
initialValue: T;
children: ReactNode;
}) => {
const [formState] = useState(() => new FormState(schema, initialValue));
return (
<formContext.Provider value={formState}>
{children}
</formContext.Provider>
);
};

We need FormState<any> when we create the context because it could literally be a form for any schema. We don’t have that information when creating the context. Pay attention to useForm as well. The best we can do is receive some type arguments for the schema, and pinky-promise TypeScript that the context will be for that (useContext<FormState<T> | null>(formContext)).

OK. Now let’s think about the TextInput. It will need some way to get the value for the field it represents. FormState currently doesn't expose its values, so let's do that first:

tsx
class FormState<T> {
// ...
// This is added to our previous definition of FormState
public getValue<Name extends keyof T>(name: Name): T[Name] {
return this.value[name];
}
}

To make it easier for components to access their respective values, we can expose it via a hook.

tsx
const useFormValue = <T, Name extends keyof T>(name: Name): T[Name] => {
// The pinky-promise bubbling
const formState = useForm<T>();
return formState.getValue(name);
};

Again, we will have to pinky-promise that the form context will at least have the T type we have received as a type argument for its working values. We are just bubbling the pinky-promise up and up.

Before we implement the TextInput, I wrote this type that will help us make text inputs safer. It returns keys in an object that have a certain type.

ts
type PathValue<T, Target> = keyof {
[K in keyof T as T[K] extends Target ? K : never]: true;
};
// Just an ilustrative example of how to use it.
type Example = PathValue<{a: string; b: number; c: string }, string>;
type Example = "a" | "c"

Now we can get to TextInput. Note how we have to keep promising a type (T) and, in this case, this will lead to us having to perform a runtime check.

tsx
const TextInput = <
T,
Name extends keyof T & PathValue<T, string> = keyof T & PathValue<T, string>
>({
name,
label,
}: {
name: Name;
label: string;
}) => {
// More pinky-promises!
const value = useFormValue<T, Name>(name);
// Runtime validation. Or we could have `useFormValue`
// validate the type with PathValue, but that wouldn't
// runtime validate, and having the runtime validation is a plus.
if (typeof value !== 'string') {
throw new Error("You pinky-promised a string, this ain't one!");
}
return (
<label>
{label}
<input type="text" value={value} />
</label>
);
};

Our form library now has the necessary pieces for us to use it and display a form field. As a reminder, if you want a more complete example where it is possible to actually type and submit the form, check out this example.

With all this, we can build a form for a person—or, to keep it simple, just a person's name.

tsx
const PersonSchema = Schema.Struct({
name: Schema.NonEmptyString,
});
 
/**
* Represents the encoded version of a person. I.e. a raw, unvalidated value
* such as what you would expect from a form or an API payload.
*/
type FormPerson = typeof PersonSchema.Encoded;
 
const PersonForm = () => (
<Form schema={PersonSchema} initialValue={{ name: '' }}>
<TextInput<FormPerson> name="name" label="Name" />
</Form>
);

This is less than ideal, because we have to pinky-promise that the text input is for a form representing a FormPerson. But fine. You can live with this.

However, remember one of the key benefits of React is composition. How would you reuse a component with one of these TextInputs across many forms? I am actually facing this right now, with create and edit versions of a form that are so slightly different, and I can only reuse part of the form by heavily relying on the pinky-promise method.

How about working with forms with nested objects? It’s not possible without pinky-promising everywhere. Once you have a couple layers of indirection (a component inside a component inside a component), it’s very hard to be sure that you are doing things right. There is no static validation of what you are doing. You’ll have to test at runtime. You lost type safety.

Alternatives

Many form libraries work with these limitations. But they also offer different workarounds to bring some type safety back.

Higher Order Context

Create context on demand, with the desired generic type. Instead of a top-level context declaration, you have a function that returns a React context for a specific type. For example:

tsx
const createFormContext<T> = (schema: Schema.Schema<T>) =>
React.createContext<FormState<T> | null>(null);

This is often exposed in a more friendly way to library users, so that they can obtain providers and hooks specific to this context:

tsx
const PersonForm = MyFormLibrary.createForm(schema)
<PersonForm.Form defaultValue={{ name: '' }}>
<PersonForm.TextInput name="name" label="Name" />
</PersonForm.Form>

And you’ve gained type safety back! Well, to an extent. Remember that TypeScript still doesn’t know if the right provider is present in the ancestors.

Also, this is still not composable, because you can’t reuse components for a subset of the form across multiple forms that use different contexts. This may be partially solvable by further complicating the context objects, making them nestable. Something like:

tsx
const AccountForm = PersonForm.createSubform('account')
const AccountFormFields = () => (
<AccountForm.Provider>
<AccountForm.Select name="service" label="Service">
{...options}
</AccountForm.Select>
</PersonForm.Provider>
)
<PersonForm.Form defaultValue={{ name: '', account: { service: '' } }}>
<PersonForm.TextInput name="name" label="Name" />
<AccountFormFields />
</PersonForm.Form>

Needless to say, this can get a bit complicated to manage.

No context

Instead of relying on injected context, pass an object that carries the context. This is one of the possible ways to use react-hook-form (this library allows mixing context and this approach).

tsx
const personForm = useForm<CreateForm>({
resolver: effectTsResolver(createSchema)
})
<form onSubmit={personForm.handleSubmit(console.log)}>
<Controller
control={personForm.control}
// This is type-safe because it has to be a key
// of the type for the control.
name="name"
render={field => <input {...field} />}
/>
</form>

This is still not composable. You always have to pass a top-level control, and the name has to be a path from the top-level object. One can also imagine subcontrols, similarly to the subforms in the previous example, but this can also be hard to implement correctly (it’s easy to run into TS limitations).

Other languages

This limitation of TypeScript and React in static type-safe dependency injection is not universal to all languages. See for example Effect erroring at the type level when the Random context is not provided, and it all working with the context being provided (see docs). Or ZIO’s contextual data types for dependency injection.

Conclusion

React Context is a useful tool for managing shared state, but TypeScript is not able to offer the same level of assurances we generally expect. It is possible to find cases where carefully managing where developers must make unsafe type assertions can achieve a compromise between convenience and type safety. However, you will now need runtime tests for cases where typically TypeScript would have you covered. This applies to the compound components pattern as well.

Prop-drilling is, in many cases, a decent alternative with a bad reputation. A careful design of component interfaces and the help of the TypeScript language server for autocomple, as well as LLMs, can make the prop-drilling experience quite nice, and type-safe.

Personally, I really value type safety, so the DX improvement has to be pretty good for me to give that up. That may be worthwhile to improve the experience of extremely common operations such as writing forms. I am also more willing to take the hit if I'm delegating the inner complexity to an external dependency.


Profile picture

I'm a Software Engineer from Catalonia based in North Carolina. I'm generally busy with work and 3 boys, but I'll try to drop some thoughts here when I can.

You can also follow me on Blueky, Twitter, LinkedIn and GitHub.

© 2024, Kilian Cirera Sant