React Higher Order Components with TypeScript
- 8/19/2017
- ·
- #typescript
- #howto
- #react
It’s hard to imagine React Higher-Order Components (HOCs) before static types came along. For React applications, higher-order components offer neat encapsulation of common behaviors. But TypeScript is the secret sauce: with static guarantees in the mix, we can know for certain that an HOC is behaving as intended.
A first pass
Take data loading. As polite developers, we’ll want to show a loading state while we await the result of user-initiated API requests.
import * as React from 'react'
const Loader: React.SFC<{}> = () =>
<div>Just a moment, please...</div>
type Props = {
isLoading: boolean,
count: number,
}
const Counter: React.SFC<Props> = ({ isLoading, count }) => {
if (isLoading) {
return <Loader />
}
return <div>
<h1>Count is {count}</h1>
</div>
}
Assuming for simplicity’s sake that isLoading
is being appropriately managed
by a state container elsewhere in the app, our stateless
functional component will either show a loading indicator or a
newly-updated count
.
Refactor
Notice the two separate concerns? While the view logic is specific to the counter component, loading states are a somewhat more general problem. Once multiple components across the application need to wait on API requests, we can save some conditional boilerplate and bespoke implementations by generalizing their collective behavior into an HOC.
For a crude first pass, let’s extract the conditional logic verbatim into a new component.
interface LoadableProps {
isLoading: boolean,
}
function loadable<P extends LoadableProps>
(C: React.ComponentClass<P>|React.SFC<P>): React.SFC<P> {
return (props) => {
if (props.isLoading) {
return <Loader />
}
return <C {...props} />
}
}
Now that the details of loading are encapsulated inside the loadable
component, the Counter
can focus entirely on presenting data. Composing them
yields a LoadableCounter
with the same behavior as our original Counter
.
type Props = LoadableProps & {
count: number,
}
const Counter: React.SFC<Props> = props => (
<div>
<h1>Count is {props.count}</h1>
</div>
)
const LoadableCounter = loadable(Counter)
But this isn’t a very satisfying implementation. The clue is in the constraint
that the generic loadable
imposes on “child” components.
interface LoadableProps {
isLoading: boolean,
}
Any child implementing the loadable
behavior will need LoadableProps
in
addition to its own.
type Props = LoadableProps & {
count: number,
}
This may not be a problem if we use isLoading
everywhere, but it’s at odds
with our effort to cleanly separate loading behavior from rendering.
Generalize
With a few small adjustments, we can break our dependency on isLoading
and
rewrite loadable
in complete ignorance of the “child” component’s props.
function loadable<P>(isLoading: (p: P) => boolean) {
return (C: React.ComponentClass<P> | React.SFC<P>): React.SFC<P> =>
(props) => {
if (isLoading(props)) {
return <div>Just a moment, please...</div>
}
// Set pretty `displayName` for debugging and dev tooling
C.displayName = `Loadable(${C.name})`
return <C {...props} />
}
}
Let’s unpack that. The higher-order function, loadable
, accepts a predicate
(isLoading
) that maps component props to a boolean. The request is either in
progress, or it isn’t.
We can now isolate loading state from the details of individual props:
type Props = {
count?: number,
}
const isCounterLoading = (p: Props) =>
typeof count === 'number';
Beneath the mapping function, loadable
functions just as before. Pass in a
component and get a Loadable
version back.
const Counter: React.SFC<Props> = props => (
<div>
<h1>Count is {props.count}</h1>
</div>
)
const LoadableCounter = loadable(isCounterLoading)(Counter)
Conclusion
While the final loadable
implementation (github example) is
marginally more complex than the naive first pass, adding a mapping function
means more flexibility at the component interface. To reimplement the original
loadable
component, for instance, we need only call loadable
with an
appropriate mapping.
const originalIsLoading = (p: LoadableProps) => p.isLoading
const originalLoadable = loadable(originalIsLoading)
It’s the best of both worlds: a clean break between view and behavior, and a family of higher-order components that are easily reused where needed.