Introduction
In this tutorial, we will cover best practices using a GitHub project available in the sidebar. Clone the project and try it out. The application is a simulated piano that produces sounds when keys are pressed:
Did you know?
React only uses two packages: React (core features) and react-dom (browser-related features). Without react-dom, JSX cannot be used because browsers do not support it. Since React 17, the React object is no longer necessary to render JSX code.
FunctionComponent
FunctionComponents allow for typing TypeScript functions and returning a React object with props. A React element is a JavaScript object that represents the state of DOM elements at any given time. Consider using FunctionComponent for typing with TypeScript.
import React, { FunctionComponent } from "react";
import "./style.css";
export const Footer: FunctionComponent = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<a href="https://switch-case.io/">Switch-case</a>
<br />
{currentYear}
</footer>
);
};
FC is used to define a React functional component:
const Issues: FC<Props> = () => {...}
It's a good option to migrate this type of code to React hooks:
class Issues extends Component<Props, State> {
Why use FunctionComponents?
- Facilitate code readability and comprehension
- Easy to test
- Potentially better performance (6% gain)
- Easy to debug
- Reusable
- Reduce coupling
When not to use them? Always start with a functional component. If it's necessary to add lifecycle methods or state at the component level, refactor to a class component. Don't use functional components if you don't have a choice.
Union type
In application development, the domain refers to the main subject of the program and helps structure the data. For this piano application, our domain concerns the sounds, generated notes, note notation, and actual piano keys.
In this TypeScript file, we use Union Type to group different types of possible notes: NoteType, NotePitch, and OctaveIndex. The Union Type allows us to create a set of entities that we can select at any time. In our case, the possible note types are "natural", "sharp", and "flat", which are separated by the "|" symbol.
Here are the possible note types for our application:
export type NoteType = "natural" | "flat" | "sharp"
export type NotePitch = "A" | "B" | "C" | "D" | "E" | "F" | "G"
export type OctaveIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
Interface
In programming, an interface is an abstract description of an entity that allows for sharing similar information between components. In TypeScript, interfaces are a powerful way to define contracts across our code, making components less dependent on each other. This makes it easier to reuse code and reduces the risk of unexpected errors.
In the example below, we define an interface for a music note that is directly related to our Union Type. This interface allows us to specify the structure and possible data that we expect for a "note" object.
export interface Note {
midi: MidiValue;
type: NoteType;
pitch: NotePitch;
index: PitchIndex;
octave: OctaveIndex;
}
By using this interface, we create a contract that ensures any "note" object will have the properties "midi", "type", "pitch", "index", and "octave". Using interfaces in this way brings greater flexibility and consistency to our components, which is a good development practice.
Generic types
In programming, generic types allow for creating components that can work with a wide variety of types. This adds flexibility by avoiding assigning a class to a component in a fixed way.
In the example below, we define different constants related to music, such as music notes and their MIDI values. We also use the type Record<K, T>, which demands a set of properties of type K for values of type T. In our case, we are constructing an object that has a set of PitchIndex of type NotePitch.
const C1_MIDI_NUMBER = 24;
const C4_MIDI_NUMBER = 60;
const B5_MIDI_NUMBER = 83;
export const LOWER_NOTE = C4_MIDI_NUMBER;
export const HIGHER_NOTE = B5_MIDI_NUMBER;
export const SEMITONES_IN_OCTAVE = 12;
export const NATURAL_PITCH_INDICES: PitchIndex[] = [0, 2, 4, 5, 7, 9, 11];
export const PITCHES_REGISTRY: Record<PitchIndex, NotePitch> = {
0: "C",
1: "C",
2: "D",
3: "D",
4: "E",
5: "F",
6: "F",
7: "G",
8: "G",
9: "A",
10: "A",
11: "B",
};
Finally, we have an example of a generic type in TypeScript, the Optional
export type Optional<TEntity> = TEntity | null
Webpack aliases
When working on JavaScript applications, one of the most common issues is managing relative paths. If you move a file or rename a directory, you'll need to modify the relative paths for each import, which can quickly become tedious. The solution to this problem is to use "aliases".
Aliases allow for defining shorter names for longer import paths. In TypeScript, they can be defined in the "tsconfig.json" configuration file:
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": "./src",
"paths": {
"Components/*": [ "components/*"],
"Domains/*": [ "domains/*"],
"Layouts/*": [ "layout/*"],
"Pages/*": [ "pages/*"],
},
},
"include": [
"src"
]
}
In this example, we have defined four aliases: "Components", "Domains", "Layouts", and "Pages", which respectively point to the "components", "domains", "layout", and "pages" folders.
Once the aliases are defined, we can use them in our files as follows:
import React, { FunctionComponent } from "react"
import { notes } from "Domains/note"
import "./style.css"
export const Keyboard: FunctionComponent = () => {
return (
<div className="keyboard">
</div>
)
}
To make this work, we also need to modify our Webpack configuration by installing the "tsconfig-paths-webpack-plugin" dependency and adding the following lines to our Webpack configuration file:
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
// ...
resolve: {
plugins: [new TsconfigPathsPlugin()],
}
// ...
}
By adding this dependency and configuration lines, we can use our aliases without having to modify the relative paths in each file.
Reusable components
Reusable components are components that can be shared between multiple domains of an application to avoid code duplication. By using reusable components, complex applications can be created while keeping a clean and maintainable interface.
In React, it is easy to compose components together. For example, we can create a "Profile" component that uses two other components: "Picture" and "UserName" to display the profile picture and user name:
const Profile = ({ user }) => (
<>
<Picture profileImageUrl ={user. profileImageUrl } />
<UserName name ={user. name } screenName ={user. screenName } />
</>
)
By creating these small components with a clean interface, we can quickly compose new parts of the user interface by writing only a few lines of code. Whenever we compose components, we share data between them using "props". This allows for creating reusable components that can be used in different parts of the application without having to rewrite the code.
The props children
The "children" property is a special prop in React that allows passing any element as a child of a component. This allows for great flexibility in composing our user interfaces.
For example, if we want to create a reusable button that can display more than just a simple text string, we can use the "children" prop for that:
const Button = ({ children }) => (
<button className="btn">{children}</button>
)
By passing the "children" prop, we are not limited to a simple text property, but we can pass any element to the Button, and it will be rendered in place of the property. For example, we can pass an image and text to our button:
<Button>
<img src="..." alt="..." />
<span>Click me!</span>
</Button>
In this example, we have defined the "children" property as an array, which means we can pass any number of elements as children of the component.
By using the "children" prop, we can create more flexible and reusable components that can accept any element and wrap them in a predefined parent.
Container and Presentation Pattern
In React, there are two simple and powerful patterns called container and presentation. These patterns can be applied when creating components to separate logic from presentation. By creating well-defined boundaries between logic and presentation, not only do components become more reusable, but it also offers many other advantages that we will discover in this section. To better understand these concepts, it is recommended to see practical examples, so let's dive a little into the code.
Let's take the example of a component that uses geolocation APIs to get the user's position and display the latitude and longitude on the page in the browser.
import { useState, useEffect } from 'react'
const Geolocation = () => {}
export default Geolocation
Let's define our states:
const [latitude, setLatitude] = useState<number | null>(null)
const [longitude, setLongitude] = useState<number | null>(null)
Now we can use the useEffect Hook to send the request to the APIs:
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess)
}
}, [])
When the browser returns the data, we store the result in the state using the following function:
const handleSuccess = ({
coords: {
latitude,
longitude
}
}: { coords: { latitude: number; longitude: number }}) => {
setLatitude(latitude)
setLongitude(longitude)
}
Finally, we display the latitude and longitude:
return (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
It is important to note that during the initial render, the latitude and longitude values are empty because the component requested the coordinates during its mount. To improve performance, it would be desirable to separate this part from the part where the position is requested and loaded.
To do this, we will use the container and presentation patterns to isolate the presentation part. Each component is divided into two smaller parts, each with clear responsibilities.
The container manages the component's logic and calls the necessary APIs. It also handles data manipulation and event management. The presentation component is where the user interface is defined. It receives data in the form of props from the container. Since the presentation component is usually without logic, we can create it as a stateless functional component. However, this does not mean that it cannot have state.
Here's an example code for a container using geolocation:
import { useState, useEffect } from 'react'
import Geolocation from './Geolocation'
const GeolocationContainer = () => {
const [latitude, setLatitude] = useState<number | null>(null)
const [longitude, setLongitude] = useState<number | null>(null)
const handleSuccess = ({ coords: { latitude, longitude } }: { coords: { latitude: number; longitude: number }}) => {
setLatitude(latitude)
setLongitude(longitude)
}
useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(handleSuccess)
}
}, [])
return (
<Geolocation latitude={latitude} longitude={longitude} />
)
}
export default GeolocationContainer
As you can see in the previous snippet, instead of creating the HTML elements inside the container, we simply return the presentation component (which we will create next), and pass it the state. The states are latitude and longitude, which are initially empty, and will contain the actual position of the user when the browser triggers the callback.
Here's an example code for a presentation component called Geolocation.tsx using the position data passed by the container:
import { FC } from 'react'
type Props = {
latitude: number
longitude: number
}
const Geolocation: FC<Props> = ({ latitude, longitude }) => (
<div>
<h1>Geolocation:</h1>
<div>Latitude: {latitude}</div>
<div>Longitude: {longitude}</div>
</div>
)
export default Geolocation
HTML or other components can be used in the presentation layer of an application, while the container layer focuses on behavior, such as API calls and data manipulation. By separating these concerns, we can create reusable components and improve code maintainability.
For example, we can create a reusable presentation component that displays fake coordinates in a style guide. If we need to display the same data structure in other parts of the application, we can simply wrap this component in a new container that loads the actual latitude and longitude from a different endpoint. Other developers on the team can improve the container logic without affecting the presentation layer.
This pattern can make a big difference in the development speed and maintainability of large applications. However, we should be careful not to overuse it, as it can lead to unnecessary files and components.
Container | Presentation |
---|---|
Focus on behavior | Focus on visual representation |
Display presentation components | Display HTML (or other components) |
Make API calls and manipulate data | Receive data from parents as props |
Define event handlers | Often written as stateless functional components |
Pattern Adapter
The Adapter pattern allows us to use an entity's interface (such as a class or service) as another interface, by providing a third-party API that can be used in our application. This pattern provides a third-party API for us to easily integrate the interface into our application. Here's a diagram that illustrates this pattern:
The Adapter design pattern provides a third-party interface to access features such as the Soundfont API and AudioContext. With this API, we can easily integrate these features into our application, even if they were designed with a different interface.
import { useState, useRef } from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface Settings {
AudioContext: AudioContextType
}
interface Adapted {
loading: boolean
current: Optional<InstrumentName>
load(instrument?: InstrumentName): Promise<void>
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
export function useSoundfont({ AudioContext }: Settings): Adapted {
let activeNodes: AudioNodesRegistry = {}
const [current, setCurrent] = useState<Optional<InstrumentName>>(
null
)
const [loading, setLoading] = useState<boolean>(false)
const [player, setPlayer] = useState<Optional<Player>>(null)
const audio = useRef(new AudioContext())
async function resume() {
return audio.current.state === "suspended"
? await audio.current.resume()
: Promise.resolve()
}
async function load(
instrument: InstrumentName = DEFAULT_INSTRUMENT
) {
setLoading(true)
const player = await Soundfont.instrument(
audio.current,
instrument
)
setLoading(false)
setCurrent(instrument)
setPlayer(player)
}
async function play(note: MidiValue) {
await resume()
if (!player) return
const node = player.play(note.toString())
activeNodes = { ...activeNodes, [note]: node }
}
async function stop(note: MidiValue) {
await resume()
if (!activeNodes[note]) return
activeNodes[note]!.stop()
activeNodes = { ...activeNodes, [note]: null }
}
return {
loading,
current,
load,
play,
stop
}
}
The Adapted
interface represents the interface of the functions provided by our adapter, including the expected return type. It defines the methods that can be used to access the features of the Soundfont API and AudioContext.
The required parameters for using the useSoundfont
function are defined in the Settings
object. This configuration allows our adapter to know the necessary information to load and use the Soundfont correctly.
The useSoundfont
function implements the methods provided by the Adapted
interface. It uses the parameters provided in the Settings
object to load the Soundfont and returns an instance of the Adapted
interface, which can be used to access the features of the Soundfont API and AudioContext.
import React, { FunctionComponent } from "react"
import { useAudioContext } from "../AudioContextProvider"
import { useSoundfont } from "Adapters/SoundFont"
import { useMount } from "../../utils/useMount"
import { Keyboard } from "../Keyboard"
import "./style.css"
export const KeyboardWithInstrument: FunctionComponent = () => {
const AudioContext = useAudioContext()!
const { loading, play, stop, load } = useSoundfont({ AudioContext })
useMount(load)
return <Keyboard loading={loading} play={play} stop={stop} />
}
In our example, we import the Adapted
interface to access the methods provided by the API. By using the useSoundfont
function, we can retrieve the loading
, play
, stop
, and load
methods, which are essential for our component. By using this API, we can reuse these features for other use cases and not just within the component itself.
This design pattern focuses on providing an intermediate API that enables us to implement and connect objects or classes that don't normally have a direct link. It simplifies the integration of third-party features into our application and promotes code reuse.
Pattern Observer
The main idea is to allow us to subscribe to events and handle them as we wish. Let's take the example of a keyboard: we want to subscribe to the keyPress
event. To do this, we start by creating the observer:
import { useEffect, useState } from "react"
import { Key as KeyLabel } from "Domains/keyboard"
type IsPressed = boolean
type EventCode = string
interface Settings {
watchKey: KeyLabel
onStartPress: Function
onFinishPress: Function
}
function fromEventCode(code: EventCode): KeyLabel {
const prefixRegex = /Key|Digit/gi
return code.replace(prefixRegex, "")
}
function equal(watchedKey: KeyLabel, eventCode: EventCode): boolean {
return (
fromEventCode(eventCode).toUpperCase() ===
watchedKey.toUpperCase()
)
}
export function usePressObserver({
watchKey,
onStartPress,
onFinishPress
}: Settings): IsPressed {
const [pressed, setPressed] = useState<IsPressed>(false)
useEffect(() => {
function handlePressStart({ code }: KeyboardEvent): void {
if (pressed || !equal(watchKey, code)) return
setPressed(true)
onStartPress()
}
function handlePressFinish({ code }: KeyboardEvent): void {
if (!pressed || !equal(watchKey, code)) return
setPressed(false)
onFinishPress()
}
document.addEventListener("keydown", handlePressStart)
document.addEventListener("keyup", handlePressFinish)
return () => {
document.removeEventListener("keydown", handlePressStart)
document.removeEventListener("keyup", handlePressFinish)
}
}, [watchKey, pressed, setPressed, onStartPress, onFinishPress])
return pressed
}
When a user presses a key, we call the handlePressStart
function to handle the event. We check that this key hasn't been pressed yet. If so, we set the pressed
variable to true
and call the onStartPress
function.
When the user releases the key, we call the onFinishPress
function, which resets the pressed
variable to its initial state and calls the onFinishPress
function.
It's important to use the removeEventListener
method to remove the key listeners once they're no longer needed. If we don't do this, it can quickly lead to memory issues as each key has its own instance and thus creates a listener for each of them.
Our observer allows us to listen to keyboard events here. We could implement this feature directly in our component, but that could limit our flexibility. By using an observer, we can listen to keyboard events generically and easily reuse them in other parts of our application.
Now that we understand how it works, let's see how to use it in our application.
import React, { FunctionComponent, ReactEventHandler } from "react"
import clsx from "clsx"
import { NoteType } from "Domains/note"
import { usePressObserver } from "Observers/PressObserver"
import "./style.css"
interface KeyProps {
type: NoteType
label: string
disabled?: boolean
onUp: ReactEventHandler<HTMLButtonElement>
onDown: ReactEventHandler<HTMLButtonElement>
}
export const Key: FunctionComponent<KeyProps> = ({
type,
label,
onDown,
onUp,
...rest
}) => {
const pressed = usePressObserver({
watchKey: label,
onStartPress: onDown,
onFinishPress: onUp
})
return (
<button
className={clsx(`key key--${type}`, pressed && "is-pressed")}
onMouseDown={onDown}
onMouseUp={onUp}
type="button"
{...rest}
>
{label}
</button>
)
}
Pattern Render props
Le concept de "render prop" se réfère à une technique de partage de code entre les composants React en utilisant une prop qui est une fonction. En termes simples, une prop de rendu est une prop d'un composant où vous pouvez passer une fonction qui doit renvoyer des éléments qui seront utilisés pour le rendu des composants. Cette technique permet de partager la logique interne d'un composant avec un autre, ce qui facilite le développement de composants réutilisables.
Pour illustrer cela, imaginons un exemple de composant avec une fonction de rendu :
<ExampleRenderPropsComponent
render={(name: string) => <div>Hello, {name}!</div>}
/>
On closer examination, we notice that we need a function that returns another React component. However, this function doesn't just render a component, it renders a component with text that contains a name. This name is a calculated value within ExampleRenderProps
. Thus, this render function connects the internal values of ExampleRenderProps
with the outside world by exposing this internal value to the outside world. The best part is that we can decide what we want to share with the outside world and what we don't want to share. We could have 100 internal values inside ExampleRenderProps
, but only expose one.
In this way, we can encapsulate logic in one place - ExampleRenderProps
- but share certain functionality with different components. This reduces code duplication and improves component reusability. By using render props, we can easily pass functions between components and share common functionality.
import {
ReactElement,
FunctionComponent,
useState,
useEffect,
useRef,
useCallback
} from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface ProvidedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
instrument?: InstrumentName
AudioContext: AudioContextType
render(props: ProvidedProps): ReactElement
}
export const SoundfontProvider: FunctionComponent<ProviderProps> = ({
AudioContext,
instrument,
render
}) => {
let activeNodes: AudioNodesRegistry = {}
const [current, setCurrent] = useState<Optional<InstrumentName>>(
null
)
const [loading, setLoading] = useState<boolean>(false)
const [player, setPlayer] = useState<Optional<Player>>(null)
const audio = useRef(new AudioContext())
const loadInstrument = useCallback(() => load(instrument), [
instrument
])
useEffect(() => {
if (!loading && instrument !== current) loadInstrument()
}, [loadInstrument, loading, instrument, current])
async function resume() {
return audio.current.state === "suspended"
? await audio.current.resume()
: Promise.resolve()
}
async function load(
instrument: InstrumentName = DEFAULT_INSTRUMENT
) {
setLoading(true)
const player = await Soundfont.instrument(
audio.current,
instrument
)
setLoading(false)
setCurrent(instrument)
setPlayer(player)
}
async function play(note: MidiValue) {
await resume()
if (!player) return
const node = player.play(note.toString())
activeNodes = { ...activeNodes, [note]: node }
}
async function stop(note: MidiValue) {
await resume()
if (!activeNodes[note]) return
activeNodes[note]!.stop()
activeNodes = { ...activeNodes, [note]: null }
}
return render({
loading,
play,
stop
})
}
By a class :
import { Component, ReactElement } from "react"
import Soundfont, { InstrumentName, Player } from "soundfont-player"
import { MidiValue } from "Domains/note"
import { Optional } from "Domains/types"
import {
AudioNodesRegistry,
DEFAULT_INSTRUMENT
} from "Domains/sound"
interface ProvidedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
instrument: InstrumentName
AudioContext: AudioContextType
render(props: ProvidedProps): ReactElement
}
interface ProviderState {
loading: boolean
current: Optional<InstrumentName>
}
export class SoundfontProvider extends Component<
ProviderProps,
ProviderState
> {
public static defaultProps = {
instrument: DEFAULT_INSTRUMENT
}
private audio: AudioContext
private player: Optional<Player> = null
private activeNodes: AudioNodesRegistry = {}
public state: ProviderState = {
loading: false,
current: null
}
private resume = async () => {
return this.audio.state === "suspended"
? await this.audio.resume()
: Promise.resolve()
}
private load = async (instrument: InstrumentName) => {
this.setState({ loading: true })
this.player = await Soundfont.instrument(this.audio, instrument)
this.setState({ loading: false, current: instrument })
}
constructor(props: ProviderProps) {
super(props)
const { AudioContext } = this.props
this.audio = new AudioContext()
}
public componentDidMount() {
const { instrument } = this.props
this.load(instrument)
}
public shouldComponentUpdate({ instrument }: ProviderProps) {
return this.state.current !== instrument
}
public componentDidUpdate({
instrument: prevInstrument
}: ProviderProps) {
const { instrument } = this.props
if (instrument && instrument !== prevInstrument)
this.load(instrument)
}
public play = async (note: MidiValue) => {
await this.resume()
if (!this.player) return
const node = this.player.play(note.toString())
this.activeNodes = { ...this.activeNodes, [note]: node }
}
public stop = async (note: MidiValue) => {
await this.resume()
if (!this.activeNodes[note]) return
this.activeNodes[note]!.stop()
this.activeNodes = { ...this.activeNodes, [note]: null }
}
public render() {
const { render } = this.props
const { loading } = this.state
return render({
loading,
play: this.play,
stop: this.stop
})
}
}
By using the render prop pattern, we have created a feature that we can provide to other components as a prop. This allows other components to use this feature as a kind of function library for their own logic.
<SoundfontProvider AudioContext={AudioContext} instrument={instrument}>
{(props) => <Keyboard {...props} />}>
</SoundfontProvider>
There are some limitations to using the render prop pattern.
Firstly, using render props can add one or two additional levels of nesting in a component that uses it, which can make the code more complex to read and understand.
Additionally, the render prop pattern requires rendering to be called, which means that the code cannot be used outside of the React rendering environment. This can limit its ability to be reused in non-React contexts.
Finally, excessive use of render props can make the code more difficult to maintain and debug, as it can be challenging to track the flow of data through the different render functions.
Despite these limitations, the render prop pattern can be a useful and flexible solution for code reuse in React components, enabling common functionality to be provided to different components without code duplication.
Higher Order Components
Let's start by looking at what a Higher Order Component (HoC) looks like.
const HoC = Component => EnhancedComponent
Higher Order Components (HoCs) are functions that take a component as input and return an enhanced component as output.
Here's a simple example to illustrate what an enhanced component created with an HoC looks like.
Let's say we need to add a common property to all of our components for some reason. We could manually add this property to each method, but that could be tedious. Instead, we can create an HoC that automatically adds this property to each component it wraps, like so:
const withClassName = Component => props => (
<Component {...props} className="my-class" />
)
In the React community, it's common to use the "with" prefix for HoCs. The previous code may seem complex at first glance, so let's break it down together.
We define a function that takes a function as input and returns another function as output. The returned function is a functional component that receives props and returns the original component. The collected props are then spread and a property with a certain value is passed to the functional component.
This code is fairly simple and not very useful on its own, but it helps us understand what HoCs are and what they look like. Now let's see how we can use the HoC in our components.
const MyComponent = ({ className }) => (
<div className={className} />
)
As you can see, by using HoCs, we can take a component as input, and then return a new component with additional functionality.
Instead of using the component directly, we pass it to an HoC, like this:
const MyComponentWithClassName = withClassName(MyComponent)
By wrapping our components in the HOC function, we ensure that they receive the additional props they need to function.
To illustrate the use of HoCs, let's take the example of connecting the Keyboard component to our SoundFont.
We can create an HoC that provides the SoundFont functionality to Keyboard. This HoC can take the Keyboard component as input, add the necessary props to use SoundFont, and return a new, enhanced Keyboard component with these additional features.
Here's what our HoC could look like:
For our SoundFont, the API remains the same as before. However, we'll now use the term "InjectedProps" instead of "ProvidedProps", as we'll be injecting these props into a component that will be enhanced by our HoC.
interface InjectedProps {
loading: boolean
play(note: MidiValue): Promise<void>
stop(note: MidiValue): Promise<void>
}
interface ProviderProps {
AudioContext: AudioContextType
instrument: InstrumentName
}
interface ProviderState {
loading: boolean
current: Optional<InstrumentName>
}
Next, we create a "withInstrument()" function that takes a component to enhance as input. We make this function generic using TypeScript generics, to specify which props will be injected. In the React community, it is common to prefix HOCs with "with".
Here's what our withInstrument() HOC would look like:
export function withInstrument<
TProps extends InjectedProps = InjectedProps
>(WrappedComponent: ComponentType<TProps>) {
The generic type "TProps" must have properties that match those defined in the "InjectedProps" type, otherwise TypeScript will give us an error.
Also note that by default, we define "TProps" as being of type "InjectedProps" using the "=" sign when defining the generic type. This works exactly like default values for arguments in functions.
Next, we create a "WithInstrument" class that we return. This class will be the container component that enhances our "WrappedComponent".
public render() {
const injected = {
loading: this.state.loading,
play: this.play,
stop: this.stop
} as InjectedProps
return (
<WrappedComponent {...this.props} {...(injected as TProps)} />
)
}
Instead of calling "this.props.render()" and passing an object with values and methods, we directly return our "WrappedComponent" with these injected values and methods. Note that we first spread the props of the original component using "this.props", then inject the additional functionalities. This ensures that the injected props will not be overwritten by other props.
Now that our HOC is created, we can use it with our Keyboard component:
import React, { FunctionComponent } from "react"
import { useInstrument } from "States/Instrument"
import { withInstrument } from "Adapters/SoundFont/withInstrument"
import { useAudioContext } from "../AudioContextProvider"
import { Keyboard } from "../Keyboard"
import "./style.css"
const WrappedKeyboard = withInstrument(Keyboard)
export const KeyboardWithInstrument: FunctionComponent = () => {
const AudioContext = useAudioContext()!
const { instrument } = useInstrument()
return (
<WrappedKeyboard
AudioContext={AudioContext}
instrument={instrument}
/>
)
}
Here we can see how to use "withInstrument()". We take a "Keyboard" component that needs the following props:
- "loading"
- "play()" and "stop()" methods
And we return "WrappedKeyboard", which needs:
- "AudioContext"
- An optional "instrument" prop
This is possible because a "Keyboard" becomes a "WrappedComponent" when calling "withInstrument()". "WrappedKeyboard" is essentially an instance of the "WithInstrument" class that renders a "Keyboard" with "InjectedProps" already injected.
HOCs have advantages, such as the ability to statically compose arguments for the future (although this can also be done with other patterns such as the factory pattern), and are a literal implementation of a decorator pattern.
However, HOCs also have disadvantages, such as a non-obvious typing strategy with the presence of generics, on-the-fly typing, and an overall higher difficulty level. Additionally, HOCs can become too verbose.