One of the qualities of our code that we should aim for is reusability. Also, following the Don’t Repeat Yourself principle makes our code more elegant. In this article, we explore writing functional React components with TypeScript using generic props. By doing so, we can create reusable and flexible components.
If you would like to revisit the basics of generics first, check out TypeScript Generics. Discussing naming conventions
Defining the use case
One of the things that contribute to good design is consistency. Implementing it means having lots of similar parts of the application. One of the components that we encounter is a table. Let’s create an example of such. For starters, we make it in a way that it displays a certain entity – posts.
interface Post {
id: number;
title: string;
body: string;
}import React, { FunctionComponent } from 'react';
import Post from '../../interfaces/Post';
import Head from './Head';
import Row from './Row';
interface Props {
posts: Post[];
}
const PostsTable: FunctionComponent<Props> = ({
posts,
}) => {
return (
<table>
<Head/>
<tbody>
{
posts.map(post => (
<Row
key={post.id}
title={post.title}
body={post.body}
/>
))
}
</tbody>
</table>
);
};The above code is pretty straightforward. Our
PostsTable
takes an array of posts and displays all of them. Let’s fetch the posts and provide our component with them.
import React from 'react';
import usePostsLoading from './usePostsLoading';
import PostsTable from './PostsTable';
const PostsPage = () => {
const { posts, isLoading } = usePostsLoading();
return (
<div>
{
!isLoading && (
<PostsTable
posts={posts}
/>
)
}
</div>
);
};
If you want to know how to write hooks like the one above, check out Race conditions in React and beyond. A race condition guard with TypeScript
The header always displays a predefined set of properties.
const Head = () => {
return (
<thead>
<tr>
<th>Title</th>
<th>Body</th>
</tr>
</thead>
);
};The same thing with the row – it displays just the title and the body.
interface Props {
title: string;
body: string;
}
const Row: FunctionComponent<Props> = ({
title,
body,
}) => {
return (
<tr>
<td>{title}</td>
<td>{body}</td>
</tr>
);
};While the above
PostsTable
component is reusable to some extent, it is not very flexible. We can just use it to display posts.
The fact is that there are high chances that we need more tables in our application, not only for posts. Creating a separate one for every type of entity is a solution, but not the most refined one.
Introducing generic components
First, let’s define the requirements for our generic table component. It needs to:
- accept an array of entities of any type,
- process an array of properties that we want to display in a table.
Taking all of the above into consideration we can specify our
Props
interface.
interface Props<ObjectType> {
objects: ObjectType[];
properties: {
key: keyof ObjectType,
label: string,
}[];
}First, we have an array of
objects
. Second, we have an array of
properties
. Our property consists of a
key
and a
title
.
A crucial thing is that the
key
needs to match the properties of the object. To do so, we use the
keyof
keyword to create a union of
string literal types. If you want to learn more, check out More advanced types with TypeScript generics
To better grasp the concept, this is an example of props that implement the above interface:
{
objects: posts,
properties: [
{
key: 'title',
label: 'Title'
},
{
key: 'body',
label: 'Content'
}
]
}
Defining a generic component
In all of the above examples, we’ve used the generic
FunctionComponent
provided by React. Unfortunately, it would be rather troublesome to use it with generic interfaces. What we can do is to type the props directly.
If we look into the
FunctionComponent
interface, it uses
PropsWithChildren,
and we can also make use of that.
To type our component, we can choose one of the two approaches. First would be to use an arrow function:
const Table = <ObjectType, >(
props: PropsWithChildren<Props<ObjectType>>
) => {
The trailing comma in
<ObjectType, >
is added due to contraints of the
.tsx
file extension. You can read more in the TypeScript Generics. Discussing naming conventions
The second approach is to use a more classic function:
function Table<ObjectType>(
props: PropsWithChildren<Props<ObjectType>>
) {The latter looks a bit cleaner, in my opinion. Therefore, I will use it in all of the below examples.
In our
Table
component, we want to iterate through all of the objects. To display a row for every object, we need to pass the
key prop. The most suitable property would be
id. To make sure that our objects have it, we can add a
constraint.
function Table<ObjectType extends { id: number }>(
props: PropsWithChildren<Props<ObjectType>>
) {Now we can be sure that the
ObjectType
contains the id.
Implementing a fully working table
Our
Table
needs a row. It is also generic and accepts a single object and an array of properties.
interface Props<ObjectType> {
object: ObjectType;
}function Row<ObjectType extends { id: number }>(
{ object, properties }: Props<ObjectType>
) {
return (
<tr>
{
properties.map(property => (
<td key={String(property.key)}>
{object[property.key]}
</td>
))
}
</tr>
);
}The
Head
, on the other hand, does not need to be generic. We provide it just with a list of properties.
interface Props {
properties: {
key: number | symbol | string,
title: string,
}[];
}const Head: FunctionComponent<Props> = ({
properties,
}) => {
return (
<thead>
<tr>
{
properties.map(property => (
<th key={String(property.key)}>{property.title}</th>
))
}
</tr>
</thead>
);
};A thing worth pointing out is that the
key
above is of type
number | symbol | string
. The above is because the key of an object by default is of the type
number | symbol | string
.
To change the above behavior, we could force the keys to be of a string type by using
keyof ObjectType & string
.
properties: {
key: keyof ObjectType,
title: string,
}[];Alternatively, we could also use the keyofStringsOnly option when compiling.
Putting it all together
Once we’ve defined all the pieces, we can put them together.
interface Props<ObjectType> {
objects: ObjectType[];
properties: {
key: keyof ObjectType,
title: string,
}[];
}function Table<ObjectType extends { id: number }>(
{ objects, properties }: PropsWithChildren<Props<ObjectType>>,
) {
return (
<table>
<Head
properties={properties}
/>
<tbody>
{
objects.map(object => (
<Row
key={object.id}
object={object}
properties={properties}
/>
))
}
</tbody>
</table>
);
}Even though the implementation of the above component might seem rather complex, its usage is very straightforward.
import React from 'react';
import usePostsLoading from './usePostsLoading';
import Table from '../Table';
const PostsPage = () => {
const { posts, isLoading } = usePostsLoading();
return (
<div>
{
!isLoading && (
<Table
objects={posts}
properties={[
{
key: 'title',
title: 'Title'
},
{
key: 'body',
title: 'Body'
},
]}
/>
)
}
</div>
);
};The TypeScript compiler understands that the
ObjectType
above is a post. Due to that, we can only provide properties that can be found in the
Post
interface.
On the other hand, we could make the
ObjectType
explicit by providing the interface.
<Table<Post>
objects={posts}
properties={[
{
key: 'title',
title: 'Title'
},
{
key: 'body',
title: 'Body'
},
]}
/>
Summary
Thanks to all of the above work, we now have a reusable
Table
component that we can use to display any entity. By doing so, we aim for reusability and make following the DRY principle easier. You can go ahead and implement more features in our
Table
. Thanks to it being generic, we can have them all across our application.
The post Functional React components with generic props in TypeScript appeared first on Marcin Wanago Blog - JavaScript, both frontend and backend.