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.
@param ― defaultValue The value you want the context to have when there is no matching
Provider in the tree above the component reading the context. This is meant
as a "last resort" fallback.
useContext<Person>(context: React.Context<Person>): Person
Accepts a context object (the value returned from React.createContext) and returns the current
context value, as given by the nearest context provider for the given context.
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:
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.
1
const
constpersonContext:React.Context<Person|null>
personContext=
createContext<Person|null>(defaultValue: Person |null): React.Context<Person |null>
Lets you create a
Context
that components can provide or read.
@param ― defaultValue The value you want the context to have when there is no matching
Provider in the tree above the component reading the context. This is meant
as a "last resort" fallback.
useContext<Person|null>(context: React.Context<Person |null>): Person |null
Accepts a context object (the value returned from React.createContext) and returns the current
context value, as given by the nearest context provider for the given context.
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:
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:
1
const
constcalculateAge: (person:Person) =>void
calculateAge= (
person: Person
person:
classPerson
Person) => {
2
// what if `person` is not a Person?
3
// What if it's undefined, null, or something else entirely?
4
}
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:
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)?
1
enum
enumServiceType
ServiceType {
2
function (enummember) ServiceType.Water = "Water"
Water='Water',
3
function (enummember) ServiceType.Electricity = "Electricity"
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.
@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map(
serviceType: ServiceType
serviceType=> (
11
<
functionOption(props: {
value:string;
children:ReactElement|string;
}):ReactElement
Option
Attributes.key?: React.Key |null|undefined
key={
serviceType: ServiceType
serviceType}
value: string
value={
serviceType: ServiceType
serviceType}>{
serviceType: ServiceType
serviceType}</
functionOption(props: {
value:string;
children:ReactElement|string;
}):ReactElement
Option>
12
))}
13
// Note this is not a valid ServiceType
14
<
functionOption(props: {
value:string;
children:ReactElement|string;
}):ReactElement
Option
value: string
value="Custom">Custom</
functionOption(props: {
value:string;
children:ReactElement|string;
}):ReactElement
Option>
15
</
functionSelect(props: {
onChange: (serviceType:ServiceType) =>void;
value:ServiceType;
children:ReactNode;
}):ReactElement
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.
Note
Feel free to explore a more complete version of this exercise. It is hopefully simple enough to allow readers to find the code relevant to each code block below, and tinker with the pieces that work around type safety. It may also be educational as an example for certain somewhat advanced React patterns, or Schema.
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?
1
const
constPersonSchema:Schema.Struct<{
name:typeof Schema.String;
dob:typeof Schema.Date;
}>
PersonSchema=
import Schema
Schema.
functionStruct<{
name:typeof Schema.String;
dob:typeof Schema.Date;
}>(fields: {
name:typeof Schema.String;
dob:typeof Schema.Date;
}):Schema.Struct<{
name:typeof Schema.String;
dob:typeof Schema.Date;
}> (+1overload)
@since ― 3.10.0
Struct({
2
name: typeof Schema.String
name:
import Schema
Schema.
classString
exportString
@since ― 3.10.0
String,
3
dob: typeof Schema.Date
dob:
import Schema
Schema.
classDate
exportDate
This schema converts a string into a Date object using the new Date
constructor. It ensures that only valid date strings are accepted,
rejecting any strings that would result in an invalid date, such as new Date("Invalid Date").
@since ― 3.10.0
Date,
4
})
5
6
const
constPersonForm: () =>React.JSX.Element
PersonForm= () => (
7
<
functionForm(props: {
schema:Schema.Schema.Any;
children:ReactNode;
}):ReactElement
Form
schema: Schema.Schema.Any
schema={
constPersonSchema:Schema.Struct<{
name:typeof Schema.String;
dob:typeof Schema.Date;
}>
PersonSchema}>
8
<
functionTextInput(props: {
name:string;
label:string;
}):ReactElement
TextInput
name: string
name="name"
label: string
label="Name" />
9
<
functionDateInput(props: {
name:string;
label:string;
}):ReactElement
DateInput
name: string
name="dob"
label: string
label="Date of birth" />
10
</
functionForm(props: {
schema:Schema.Schema.Any;
children:ReactNode;
}):ReactElement
Form>
11
)
What would the implementation of our Form component look like? Something like
the following, to start simple.
@param ― defaultValue The value you want the context to have when there is no matching
Provider in the tree above the component reading the context. This is meant
as a "last resort" fallback.
Accepts a context object (the value returned from React.createContext) and returns the current
context value, as given by the nearest context provider for the given context.
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:
1
classFormState<T> {
2
// ...
3
// This is added to our previous definition of FormState
function (typeparameter) Tin <T, NameextendskeyofT>(name:Name):T[Name]
T,
function (typeparameter) Namein <T, NameextendskeyofT>(name:Name):T[Name]
Nameextendskeyof
function (typeparameter) Tin <T, NameextendskeyofT>(name:Name):T[Name]
T>(
name: Name extends keyof T
name:
function (typeparameter) Namein <T, NameextendskeyofT>(name:Name):T[Name]
Name):
function (typeparameter) Tin <T, NameextendskeyofT>(name:Name):T[Name]
T[
function (typeparameter) Namein <T, NameextendskeyofT>(name:Name):T[Name]
Name] => {
2
// The pinky-promise bubbling
3
const
constformState:FormState<T>
formState=
constuseForm: <T>() =>FormState<T>
useForm<
function (typeparameter) Tin <T, NameextendskeyofT>(name:Name):T[Name]
T>();
4
return
constformState:FormState<T>
formState.
FormState<T>.getValue<Name>(name: Name): T[Name]
getValue(
name: Name extends keyof T
name);
5
};
> I’m focusing on obtaining the value from the context, but in a real form library, we’d need a way to send the new value up to the state (e.g. as the user inputs).
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.
A more common API is to provide a Controller or similarly named component, that then can be made to work with any underlying input—text, date, number, or anything else. My choice here just keeps the example simpler.
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.
1
const
constPersonSchema:Schema.Struct<{
name:typeof Schema.NonEmptyString;
}>
PersonSchema=
import Schema
Schema.
functionStruct<{
name:typeof Schema.NonEmptyString;
}>(fields: {
name:typeof Schema.NonEmptyString;
}):Schema.Struct<{
name:typeof Schema.NonEmptyString;
}> (+1overload)
@since ― 3.10.0
Struct({
2
name: typeof Schema.NonEmptyString
name:
import Schema
Schema.
classNonEmptyString
@since ― 3.10.0
NonEmptyString,
3
});
4
5
/**
6
* Represents the encoded version of a person. I.e. a raw, unvalidated value
7
* such as what you would expect from a form or an API payload.
8
*/
9
type
typeFormPerson= {
readonlyname:string;
}
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.
Here too there may be other valid or better APIs. For example, instead of a Form component that replaces the native form, you may choose to expose a more literal provider, and let users render the form manually.
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:
This is often exposed in a more friendly way to library users, so that they can
obtain providers and hooks specific to this context:
1
constPersonForm= MyFormLibrary.createForm(schema)
2
3
<PersonForm.FormdefaultValue={{ name: '' }}>
4
<PersonForm.TextInputname="name"label="Name" />
5
</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:
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).
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.