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
constpersonContext =createContext <Person >(newPerson ())constPersonCard = () => {constperson =useContext (personContext )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
constpersonContext =createContext <Person | null>(null)constPersonCard = () => {constperson =useContext (personContext )// TypeScript doesn't know if a person is provided. We have to check.if (!person ) throw newError ('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
constcalculateAge = (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 any
s, 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
enumServiceType {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
importReact from 'react';import {Schema } from 'effect'import {Form ,TextInput ,DateInput } from './OurSuperCoolFormLibrary'constPersonSchema =Schema .Struct ({name :Schema .String ,dob :Schema .Date ,})constPersonForm = () => (<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
classFormState <T > {public readonlyschema :Schema .Schema <T >;/* the current state of the form object */privatevalue :T ;constructor(schema :Schema .Schema <T >,initialValue :T ) {this.schema =schema ;this.value =initialValue ;}}constformContext =createContext <// formContext has to support any type for FormState.|FormState <any>| null>(null);constuseForm = <T extends unknown>():FormState <T > => {// Pinky-promise that the FormState will hold TconstformState =useContext <FormState <T > | null>(formContext );if (!formState ) {throw newError ('Form context used outside of a <Form>');}returnformState ;};constForm = <T extends unknown>({schema ,initialValue ,children ,}: {schema :Schema .Schema <T >;initialValue :T ;children :ReactNode ;}) => {const [formState ] =useState (() => newFormState (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 FormStatepublic 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
constuseFormValue = <T ,Name extends keyofT >(name :Name ):T [Name ] => {// The pinky-promise bubblingconstformState =useForm <T >();returnformState .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
typePathValue <T ,Target > = keyof {[K in keyofT asT [K ] extendsTarget ?K : never]: true;};// Just an ilustrative example of how to use it.typeExample =PathValue <{a : string;b : number;c : string }, string>;
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
constTextInput = <T ,Name extends keyofT &PathValue <T , string> = keyofT &PathValue <T , string>>({name ,label ,}: {name :Name ;label : string;}) => {// More pinky-promises!constvalue =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 (typeofvalue !== 'string') {throw newError ("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
constPersonSchema =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.*/typeFormPerson = typeofPersonSchema .Encoded ;constPersonForm = () => (<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)}><Controllercontrol={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.