Don't you just love the warm spring evenings when there's time to stop and take a breath, feel the breeze and watch all the code // TODO DRY
while the pipeline runs? Then as the last embers of the sprint burndown chart die out you look around at all the components for selecting a value from a list: <UserListWithSearchBar/>
, <ProjectPicker/>
and <PermissionSelector/>
and think to yourself: there is a pattern to this!
And that's how TypeScript generics finally meet React components in your codebase. But the journey can turn out to be much less simple and straightforward than it sounds.
In this article, I'll try to show you my approach to the challenges I faced when creating reusable, generic React components using TypeScript. I would like to point out that:
- I am going to be focusing on the TypeScript side of things rather than UI/UX
- I am going to assume you are familiar with TypeScript and have used it to build a React component before
- I am going to explore different approaches during our journey, not just show you the final solution
Preface
What we are going to be doing is building a component that allows you to find and select an item from a list. Our goal is a statically typed component that we can use all over our codebase without repeating the same thing over and over.
Our component will render a list of items (passed as a prop, let's call it items
). When the user selects or deselects an item our component will notify us by calling a prop (let's call that one onChange
). If we pass a value prop the component will mark the matching item in items as selected.
Reminds you of the good old <select/>
right? But what is interesting about this component is that as opposed to <select>
it works with values of any type! Any type? ANY TYPE!
Act 1: The props
Let's start by defining the props of our new component since they define the API we will use to interact with it:
export interface SelectProps<T> {
// The list of items
items: T[];
// The selected item
value?: T;
// And our change handler
onChange: (value: T) => void;
}
Act 2: The component definition
Now that we have our props in place, let's define the component itself. This might prove to be more difficult than expected - TypeScript will not allow us to make the component generic! Just look at the following example:
// Attempt 1: Add generic props to React.FC type
//
// TypeScript will not allow a const to be generic like this!
export const Select: React.FC<SelectProps<T>> = props => {
// Oh no!
}
// Attempt 2: Make the component function generic
//
// JSX parser will colide with TypeScript when trying to do something like this
// and the code will not compile since <T> looks like a JSX tag!
export const Select = <T>(props: SelectProps<T>) => {
// Oh no!
}
You will see that both attempts above will result in a compilation error - in the first case TypeScript does not allow us to add a generic type to const
(it will say T
could not be found), in the second case TypeScript thinks <T>
is actually a JSX tag, not a type parameter. But we cannot stop now that we wrote our props!
So let's travel back in time to when arrow functions were not a thing and use their ancient ancestor - a function
keyword:
export function Select<T>(props: SelectProps<T>) {
// It works!
}
Great! But some of you might have already noticed that we lost something in the process. We defined a generic function Select
that takes a single parameter, but nowhere did we specify that Select
is a React component - if you now go ahead and return a Promise
from Select
, or specify invalid defaultProps
TypeScript will not say a thing:
export function Select<T>(props: SelectProps<T>) {
// This should not be allowed for React components!
return Promise.resolve(null);
}
// And we don't get a warning when defining defaultProps either :/
Select.defaultProps = 7;
Now there are three types of developers in this world:
The optimist might shrug and say If the tests pass then dunno put an any wherever you need to make the pipeline green
The nitpicker will do anything to make sure the compiler will warn them before horrible things start happening
The pessimist will stare into the void thinking Oh god why have I not become a hairdresser when there was still time
And although all of them are valid reactions, it's The nitpicker I want to focus on. Let's see what they might try when making sure their code is not vulnerable to for example a hasty code review in a stressful situation.
The first approach we might try is just adding a return type to our Select
:
// Approach 1: the easy way out
//
// We can just define the return type of our Select
// and make sure it matches the return type of React.FC
function Select<T>(props: SelectProps<T>): React.ReactElement | null {
return null;
}
// Unfortunately we can still pass invalid defaultProps :(
Select.defaultProps = 7;
But typing React.ReactElement | null
feels a bit too fragile for my taste - it can easily get out of sync with React types. On top of that, we can still pass invalid defaultProps
!
So it's back to the drawing board. What if we create a helper type, AssertComponent
, that will show an error if we don't pass a valid React component as a parameter? Here's what I mean:
// Approach 2: diving back in looking for a better way
//
// We can create a type that accepts a type parameter
// and puts a constraint on it, in our case we will demand
// the type parameter to be a React.FC
type AssertComponent<C extends React.FC<any>> = C;
// Then we use it in our Select component
function Select<T>(props: SelectProps<T>) {
return null;
}
// If the Select does not look like a React component, this line will trigger an error
type AssertSelect = AssertComponent<typeof Select>;
// However we ended up with an unused type AssertSelect :(
// And we can still pass invalid defaultProps :(((
Select.defaultProps = 7;
No progress made! We don't have any React.ReactElement | null
in our code but we introduced two types, one of which is not even used. I think we're in trouble here.
Unless…
// Approach 3: the light at the end of the tunnel
//
// TypeScript 3.7 introduced "assertion functions" that
// allow us to define an assertion function.
// We might use such function to ensure that anything we pass to it is a React.FC
// while writing no code whatsoever! BINGO!
function assertFC<P>(component: React.FC<P>): asserts component is React.FC<P> {
// We don't need to do anything here because the assertion happens
// on the type level - we need to pass a valid React component
}
// Then we use it in our Select component
function Select<T>(props: SelectProps<T>): React.ReactElement | null {
return null;
}
assertFC(Select);
OMG! The return type is checked, defaultProps
work as expected, what more could we want? And all thanks to the quite recent TypeScript feature called assertion functions.
Assertion functions are very similar to type guards with one key difference - once called, they will affect the whole scope (the whole file in our case) and will give errors when returning a meaningless value or when setting invalid defaultProps on Select. Awwwww yissssss.
Act 3: The props, revisited
Now that we have our component defined and typed let's look at the implementation. We'll run into a problem almost immediately:
export function Select<T>({ items }: SelectProps<T>) {
return <div>
{items.map(item => {
const selected = /* We now need to know how to check whether this item is the selected one */;
return <div key={/* We will also need to get a unique identifier from the type T */}/>;
})}
</div>;
}
It looks like we must have forgotten a prop! Since we don't know anything about the type T
we need some help knowing how to get a unique identifier for such a value. We also need to know how to check which value is selected. So let's adjust the props, let's add an idFromValue
prop that turns a value of type T
into something that can be used as a key:
export interface SelectProps<T> {
// ... Previous props
idFromValue: (value: T) => string | number;
}
idFromValue
will accept a value of type T
and return its "id", for example, value => value.id
or value => value.type + value.subtype
, depending on what our T
is. So let's adjust our component:
export function Select<T>({ items, value, idFromValue }: SelectProps<T>) {
// selectedId will be a string/number value that we can use to identify the selected item
const selectedId = value === undefined ? undefined : idFromValue(value);
return <div>
{items.map(item => {
const id = idFromValue(item);
// selected will now be true for values with matching IDs
const selected = id === selectedId;
return <div key={id}/>;
})}
</div>;
}
But we are still only rendering a dummy div instead of anything useful. And again, not knowing anything about the type T
we will need an extra hand, how else is Select
supposed to know what to render?
We could copy the approach we used for idFromValue
- we could add a prop, let's call it labelFromValue
, that would transform type T
into something that React can render (in other words it would return a React.ReactNode
). Then we could wrap this value in some presentational markup like so:
// Approach 1: let's define a prop that turns a value into a ReactNode
export interface SelectProps<T> {
// ... Previous props
labelFromValue: (value: T) => React.ReactNode;
}
export function Select<T>({ items, value, idFromValue, labelFromValue, onChange }: SelectProps<T>) {
const selectedId = value === undefined ? undefined : idFromValue(value);
// We will define a little helper just to make things cleaner
const isSelected = (id: string | number) => id === selectedId;
// And a single toggle handler that we pass down to all items
const handleToggle = (value: T) => onChange?.(isSelected(idFromValue(value)) ? undefined : value);
return <div>
{items.map(item => {
const id = idFromValue(item);
const selected = isSelected(id);
const label = labelFromValue(item);
return <div key={id}>
<label>
{/* For brevity I decided to use a simple checkbox to show the selected state */}
<input type="checkbox" checked={selected} onChange={handleToggle}/>
{/* And here we render our label */}
<span>{label}</span>
</label>
</div>;
})}
</div>;
}
But this way our Select
would always look the same! Always a checkbox and a label… I don't know about you but that's not what I call customizable, that's just… sad. Plus I bet some of you already got triggered - yes, the bunch of random <div/>
and <label/>
tags we return from items.map
should be moved to a separate component to keep things clean.
So let's try taking that idea further. Instead of having Select render the HTML, we will move all the rendering into a whole new component - let's call it SelectItem
. This component will be generic too, we will call its props SelectItemProps<T>
. We then pass such component to our Select
using a new prop called itemComponent
:
// Approach 2: let's define a whole new component type and let it handle the item rendering completely
//
// This way we are free to handle the rendering and selecting/deselecting anyway we want
export interface SelectProps<T> {
// ... previous props
itemComponent: React.ComponentType<SelectItemProps<T>>;
}
// These will be the props of our new item component
export interface SelectItemProps<T> {
selected: boolean;
value: T;
onToggle: (value: T) => void;
}
export function Select<T>({ items, value, idFromValue, itemComponent: ItemComponent, onChange }: SelectProps<T>) {
const selectedId = value === undefined ? undefined : idFromValue(value);
const isSelected = (id: string | number) => id === selectedId;
const handleToggle = (value: T) => onChange?.(isSelected(idFromValue(value)) ? undefined : value);
return <div>
{items.map(item => {
const id = idFromValue(item);
const selected = isSelected(id);
// We need to rename the itemComponent prop to something that starts
// with an uppercase letter because if we write
//
// <itemComponent ... />
//
// React is going to think we want to use a HTML tag called "itemComponent"
return <ItemComponent key={id} value={item} selected={selected} onToggle={handleToggle} />;
})}
</div>;
}
Looks good! Select
became very small, easily testable, and we can customize its UI and UX by defining a SelectItem that fits our use-case.
There is a drawback though, one that might become obvious only as our codebase grows. Since SelectItem
is now responsible for both knowing how to render T
and for rendering the layout (those <div/>
s and <label/>
s), we would need to define a new SelectItem
for every combination of T
and UI! Oh noooo!
After a minute or two of intense head-scratching (and a dash of procrastination), a new idea appears - why not combine the labelFromValue
approach with the itemComponent
approach into something like this:
// Approach 3: The compromise
//
// We will add both labelFromValue and itemComponent props to our Select
export interface SelectProps<T> {
// ... previous props
itemComponent: React.ComponentType<SelectItemProps<T>>;
labelFromValue: (value: T) => React.ReactNode;
}
// We will keep the itemComponent props from before, the only extra thing
// that we add is the children prop.
//
// (React actually adds the children prop automatically, I am only doing this
// to be extra explicit)
export interface SelectItemProps<T> {
children: React.ReactNode;
selected: boolean;
value: T;
onToggle: (value: T) => void;
}
export function Select<T>({ items, value, idFromValue, labelFromValue, itemComponent: ItemComponent, onChange }: SelectProps<T>) {
const selectedId = value === undefined ? undefined : idFromValue(value);
const isSelected = (id: string | number) => id === selectedId;
const handleToggle = (value: T) => onChange?.(isSelected(idFromValue(value)) ? undefined : value);
return <div>
{items.map(item => {
const id = idFromValue(item);
const selected = isSelected(id);
// The item label comes from the labelFromValue prop
const label = labelFromValue(item);
// And the UI and UX comes from the itemComponent
return <ItemComponent key={id} value={item} selected={selected} onToggle={handleToggle}>
{label}
</ItemComponent>;
})}
</div>;
}
Perfect! We have separated the logic that turns the T
into a React.ReactNode
from the logic that displays checkboxes. That's always good.
We can now implement SelectItem
s to match our UI and UX needs, the create labelFromValue
and idFromValue
functions, pass them to Select
and our work is done here.
So it looks like we accomplished what we were hoping for - we have our generic and customizable React component ready!
Unless…
Act 4: The return of the product owner
Materialized into a real-life person, change requests creep into your lovely new component. Disguised as something easy, a ticket lands on your sprint board demanding Select
to be able to select more than one item. On a technical refinement session, you agree that if a truthy multiple
prop is passed to Select
then it will allow multiple selections.
The single select version of Select
should stay the same, you pass an array of items
, possibly one selected value
and an onChange
handler that is called with either undefined
or a value from the items
array.
The multiple select version should also accept an array of items
, however now we can pass an array to our value
prop and our onChange
handler will be called with an array of values from items
. The array will be empty if there is nothing selected.
What does that mean for our code? What types need to change? How could we accomplish this polymorphism? Could we still try becoming a hairdresser instead?
Enter type narrowing. It allows us to change the shape of the props depending on the value of the new multiple
prop. All we need to do is create separate sets of props for all possible values of multiple
- in our case true
and false
(but you can easily extend this approach to numbers, string literals, etc.).
// We will start by defining the props that both the single
// and the multiple versions of our Select have in common
export interface BaseSelectProps<T> {
items: T[];
idFromValue: (value: T) => string | number;
labelFromValue: (value: T) => React.ReactNode;
itemComponent: React.ComponentType<SelectItemProps<T>>;
}
// We then define props specific for the single version
export interface SingleSelectProps<T> extends BaseSelectProps<T> {
multiple: false;
value?: T;
onChange: (value: T) => void;
}
// And props specific for the multiple version
export interface MultiSelectProps<T> extends BaseSelectProps<T> {
multiple: true;
value?: T[];
onChange: (value: T[]) => void;
}
// Finally we create a type that covers both the single and the multiple version of Select
export type SelectProps<T> = MultiSelectProps<T> | SingleSelectProps<T>;
In the example above we defined common props, BaseSelectProps
, that are shared by both versions of Select
. We then defined separate props for the single (SingleSelectProps
) and multiple (MultipleSelectProps
) versions. Then we defined SelectProps
as a union of these.
An alternative approach is to exchange interfaces for types and make use of &
type intersection operator, I am sure you can make the necessary adjustments if you prefer this approach.
Let's now look at the changes we need to make in our component code. Since the single and multiple versions differ in how they receive their value
and how they call onChange
, we will need to change our logic to reflect this.
// We can no longer destructure the props - after desctructuring the link
// between our multiple prop and the value/onChange props would vanish
export function Select<T>(props: SelectProps<T>) {
const { idFromValue, itemComponent: ItemComponent, labelFromValue } = props;
// We now "normalize" the props that can take different forms; value and onChange
//
// First we always convert the selected value(s) into an array.
//
// I hope you'll excuse my nested ternary operators and poor choice of a data structure,
// it's been done keeping the article length in mind
const selectedValues = props.multiple ? props.value || [] : props.value === undefined ? [] : [props.value];
const selectedIds = selectedValues.map(idFromValue);
const isSelected = (id: string | number) => selectedIds.includes(id);
// Then we create a toggle even handler based on the value of the multiple prop
const handleToggle = props.multiple
? // In the multiple version we will add/remove the item from the array of selected values
(item: T) => {
const id = idFromValue(item);
const wasSelected = isSelected(id);
// If the value was already selected we will remove it from the array
// otherwise we append it to the end
const newValue = wasSelected
// If the value was already selected we will remove it from the array
? selectedValues.filter(v => idFromValue(v) !== id)
// If it was not selected we append it to the array
: [...selectedValues, item];
props.onChange(newValue);
}
: // In the single version we just call onChange with the toggled item
props.onChange;
return (
<div>
{props.items.map(item => {
const id = idFromValue(item);
const selected = isSelected(id);
const label = labelFromValue(item);
return (
<ItemComponent key={id} value={item} selected={selected} onToggle={handleToggle}>
{label}
</ItemComponent>
);
})}
</div>
);
}
// ...
// Optional properties are a pain when it comes to type narrowing
// and will often produce cryptic errors. That's why defined multiple
// prop as required in both single and multiple versions.
//
// We however don't want to be repeating multiple={false} for all those Selects
// we have created before we had the multiple prop.
Select.defaultProps = {
multiple: false;
}
Love it! The product owner is happy, the compiler is happy, the QA is happy, life is good! Our Select is now generic, customizable, and flexible enough to support all our use-cases. Time for some well-deserved procrastination!
Conclusion
Now that we're all done let's look back at what we've learned:
How to define Generic React components so that we can make our code more reusable while keeping it strongly typed
How to use Type assertion functions to protect our code from cryptic errors and accidental breaking
How to utilize Type narrowing that allows our component to change behavior based on a prop
I hope these will help you create not just any
but the most stable and sturdy codebase ever. If you want to see the complete code please check out the example repository on GitHub. And if you have any questions or remarks don't hesitate to drop me a line in the comments section below!
Top comments (1)
Amazing. Thanks so much