Here In this article, I wanted to explain on how typescript generics works for a react component with an example. Before going to that, lets understand what exactly is Generics.
What are Generics ?
Generics are a way to write reusable code that works with multiple types, rather than just one specific type.
This means that instead of writing separate functions or components for each type we want to work with, we can write a single generic function or component that can handle any type we specify when we use it.
For example, let's say you want to write a function that returns the first element of an array. You could write a separate function for each type of array you might encounter, like an array of numbers, an array of strings, and so on. But with generics, you can write a single function that works with any type of array.
Generics are indicated using angle brackets (< >) and a placeholder name for the type. For example, you might define a generic function like this:
function firstItem<T>(arr: T[]): T {
return arr[0];
}
The <T>
in the function signature indicates that this is a generic function, and T is the placeholder name for the type. Now you can use this function with any type of array:
const numbers = [1, 2, 3];
const strings = ['a', 'b', 'c'];
console.log(firstItem(numbers)); // 1
console.log(firstItem(strings)); // 'a'
This is just a simple example, but generics can be used in much more complex scenarios to write highly flexible and reusable code.
How can we use it for react components ?
Now let's look at the code below.
interface Animal<T extends string> {
species: T
name: string
// Define other properties as needed
}
// Define custom types that extend the Animal type
interface Cat extends Animal<'cat'> {
color: string
}
interface Dog extends Animal<'dog'> {
breed: string
}
The first thing we see is the definition of the Animal
interface, which is a generic type that extends multiple custom types. The T
type parameter is used to specify the species of the animal, which can be either 'cat' or 'dog'. The Cat
and Dog
interfaces are custom types that extend the Animal
type and add additional properties like color
and breed
.
// Define the state type as an object with a "data" property of type Animal or null
interface State<T extends Animal<string> | null> {
data: T
}
// Define the action types for useReducer
type Action<T extends Animal<string>> =
| { type: 'SET_DATA'; payload: T }
| { type: 'CLEAR_DATA' }
| { type: 'CHANGE_SPECIES'; payload: string }
// Define the reducer function for useReducer
function reducer<T extends Animal<string>>(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case 'SET_DATA':
return { ...state, data: action.payload }
case 'CLEAR_DATA':
return { ...state, data: null }
case 'CHANGE_SPECIES':
if (state.data) {
return {
...state,
data: { ...state.data, species: action.payload },
}
}
return state
default:
throw new Error(`Unhandled action : ${action}`)
}
}
Next, we see the definition of the State
interface, which is also a generic type that accepts any type that extends the Animal
type or null
. This interface defines an object with a single property data
, which can be either of the generic type or null.
After that, we define the Action
type, which is also a generic type that accepts any type that extends the Animal
type. This type specifies the different actions that can be dispatched by the reducer function. In this case, there are three possible actions: SET_DATA
, CLEAR_DATA
, and CHANGE_SPECIES
.
The reducer function itself is also a generic function that accepts two parameters: the state object of type State<T>
and the action object of type Action<T>
. The T type parameter is used to specify the generic type that is passed to the State
and Action
interfaces. The reducer function is responsible for handling the different actions and updating the state accordingly.
// Create a context for the state and dispatch functions to be passed down to child components
interface AnimalContextType<T extends Animal<string> | null> {
state: State<T>
dispatch: React.Dispatch<Action<T>>
}
const AnimalContext = createContext<AnimalContextType<Cat | Dog | null>>({
state: { data: null },
dispatch: () => {},
})
interface AnimalProviderProps {
children: React.ReactNode
}
// Define a component that fetches data from an API and updates the state
function AnimalProvider({ children }: AnimalProviderProps) {
const [state, dispatch] = useReducer(reducer, { data: null } as State<Cat | Dog | null>)
useEffect(() => {
// Fetch data from the API and update the state
// You'll need to replace the URL with the actual API endpoint
fetch('https://example.com/api/animal')
.then((response) => response.json())
.then((data: Cat | Dog) => {
// Update the state with the fetched data
dispatch({ type: 'SET_DATA', payload: data })
})
.catch((error) => {
console.error(error)
})
}, [])
return (
<AnimalContext.Provider value={{ state, dispatch }}>
{/* Render child components */}
{children}
</AnimalContext.Provider>
)
}
The AnimalContextType
interface is another generic interface that specifies the shape of the context object. It accepts any type that extends the Animal type or null. This interface defines two properties: state
and dispatch
, which are used to manage the state and dispatch actions.
The AnimalProvider
component is a regular React component that wraps the AnimalComponent
component and provides access to the context object. It uses the useReducer hook to manage the state and the useEffect hook to fetch data from an API and update the state accordingly.
function AnimalComponent<T extends Animal<string>>() {
const { state, dispatch } = useContext(AnimalContext)
const handleChangeSpecies = (event: React.ChangeEvent<HTMLSelectElement>) => {
dispatch({ type: 'CHANGE_SPECIES', payload: event.target.value })
}
return (
<div>
{state.data && (
<div>
<h2>{state.data.name}</h2>
<p>Species: {state.data.species}</p>
{state.data.species === 'cat' && <p>Color: {(state.data as Cat).color}</p>}
{state.data.species === 'dog' && <p>Breed: {(state.data as Dog).breed}</p>}
<select value={state.data.species} onChange={handleChangeSpecies}>
<option value='cat'>Cat</option>
<option value='dog'>Dog</option>
</select>
</div>
)}
</div>
)
}
Finally, the AnimalComponent
component is also a generic component that accepts any type that extends the Animal type. It uses the useContext hook to access the context object and render the state data. It also provides a dropdown menu to allow the user to change the species of the animal and dispatch the CHANGE_SPECIES
action.
In summary, what I demonstrated is how TypeScript generics can be used to write reusable code that can work with different types. By using generics, we can write a single component that can handle any type of animal, rather than writing separate components for each species.
Full code below:
import React, { createContext, useReducer, useEffect, useContext } from 'react'
// Define the Animal type as a generic type that extends multiple custom types
interface Animal<T extends string> {
species: T
name: string
// Define other properties as needed
}
// Define custom types that extend the Animal type
interface Cat extends Animal<'cat'> {
color: string
}
interface Dog extends Animal<'dog'> {
breed: string
}
// Define the state type as an object with a "data" property of type Animal or null
interface State<T extends Animal<string> | null> {
data: T
}
// Define the action types for useReducer
type Action<T extends Animal<string>> =
| { type: 'SET_DATA'; payload: T }
| { type: 'CLEAR_DATA' }
| { type: 'CHANGE_SPECIES'; payload: string }
// Define the reducer function for useReducer
function reducer<T extends Animal<string>>(state: State<T>, action: Action<T>): State<T> {
switch (action.type) {
case 'SET_DATA':
return { ...state, data: action.payload }
case 'CLEAR_DATA':
return { ...state, data: null }
case 'CHANGE_SPECIES':
if (state.data) {
return {
...state,
data: { ...state.data, species: action.payload },
}
}
return state
default:
throw new Error(`Unhandled action : ${action}`)
}
}
// Create a context for the state and dispatch functions to be passed down to child components
interface AnimalContextType<T extends Animal<string> | null> {
state: State<T>
dispatch: React.Dispatch<Action<T>>
}
const AnimalContext = createContext<AnimalContextType<Cat | Dog | null>>({
state: { data: null },
dispatch: () => {},
})
interface AnimalProviderProps {
children: React.ReactNode
}
// Define a component that fetches data from an API and updates the state
function AnimalProvider({ children }: AnimalProviderProps) {
const [state, dispatch] = useReducer(reducer, { data: null } as State<Cat | Dog | null>)
useEffect(() => {
// Fetch data from the API and update the state
// You'll need to replace the URL with the actual API endpoint
fetch('https://example.com/api/animal')
.then((response) => response.json())
.then((data: Cat | Dog) => {
// Update the state with the fetched data
dispatch({ type: 'SET_DATA', payload: data })
})
.catch((error) => {
console.error(error)
})
}, [])
return (
<AnimalContext.Provider value={{ state, dispatch }}>
{/* Render child components */}
{children}
</AnimalContext.Provider>
)
}
function AnimalComponent<T extends Animal<string>>() {
const { state, dispatch } = useContext(AnimalContext)
const handleChangeSpecies = (event: React.ChangeEvent<HTMLSelectElement>) => {
dispatch({ type: 'CHANGE_SPECIES', payload: event.target.value })
}
return (
<div>
{state.data && (
<div>
<h2>{state.data.name}</h2>
<p>Species: {state.data.species}</p>
{state.data.species === 'cat' && <p>Color: {(state.data as Cat).color}</p>}
{state.data.species === 'dog' && <p>Breed: {(state.data as Dog).breed}</p>}
<select value={state.data.species} onChange={handleChangeSpecies}>
<option value='cat'>Cat</option>
<option value='dog'>Dog</option>
</select>
</div>
)}
</div>
)
}
// Finally, use the AnimalProvider component to wrap child components and provide access to the state and dispatch functions
function App() {
return (
<AnimalProvider>
<AnimalComponent />
</AnimalProvider>
)
}
Top comments (0)