As of React 16.8, there’s a new tool in the toolbox: Hooks. Hooks are used in functional React components, extending their functionality to include state and side effects.
I recommend reading this article by Dan Abramov for a deeper understanding of hooks. It shows how the coding style moves away from imperative class code to declarative code.
I realised the beauty of hooks in user interface components when creating a component which shows the current time in a recording. We’ve recently started using Functional Reactive Streams for the frontend at Lookback (specifically, we’re using the xstream library, but the concepts are similar in other libraries). Explaining the concept with streams is out of scope in this post, but the gist is that you manage state as immutable streams of values. This post is a nice intro to streams/reactive programming.
Streams actually play well with React. It’s basically, on a conceptual level, all about doing a .map
on a stream of values and draw the React virtual DOM based on the values. But React components must either have a render()
function which return JSX, or be a function which returns JSX. How do we render our current time from a stream?
An intuitive approach of mine would be:
import React from 'react';
import { Stream } from 'xstream';
interface Props {
currentTime$: Stream<number>;
}
// This won't work: our component can't return a Stream!
const CurrentTime = (props: Props) => props.currentTime$.map((time) => <time>{time}</time>);
We can’t return a stream from a React component – it must somehow devour the stream and return JSX.
The class(ical) approach
So we need to take a more verbose approach:
- Subscribe to the stream in a class component.
- For each new value, set the local state to the value.
- Render based on local state.
- Tear down the subscription on unmount.
Very well then:
import React from 'react';
import { Stream, Subscription } from 'xstream';
import formatTime from './libs/format-time';
interface Props {
currentTime$: Stream<number>;
}
interface State {
timeInSeconds: number;
}
export default class TimeCounter extends React.PureComponent<Props, State> {
sub?: Subscription;
constructor(props: Props) {
super(props);
this.state = {
timeInSeconds: 0,
};
}
componentDidMount(): void {
this.sub = this.props.currentTime$.subscribe({
next: (time) => {
this.setState({ timeInSeconds: time });
},
});
}
componentWillUnmount(): void {
this.sub && this.sub.unsubscribe();
}
render(): React.ReactNode {
const { timeInSeconds } = this.state;
// Renders something like: "00:10"
return <time>{formatTime(timeInSeconds)}</time>;
}
}
This totally works, but yeah… Very verbose and imperative. Hooks to the rescue!
The Hook approach
Let’s follow the intro to Hooks from the React website, and convert our class to a function:
import React, { useEffect, useState } from 'react';
import { Stream } from 'xstream';
import formatTime from './libs/format-time';
interface Props {
currentTime$: Stream<number>;
}
const TimeCounter = (props: Props) => {
// Create a state hook for our time state, with initial state of zero:
const [current, setCurrent] = useState<number>(0);
useEffect(() => {
// On component mount, set up a side effect that subscribes to
// the stream, and let `setCurrent` be called for new values:
const sub = props.currentTime$.subscribe({
next: setCurrent,
});
// Unsubscribe on component tear down:
return () => sub.unsubscribe();
});
// Render our current time state as the returned JSX!
return <time>{formatDuration(currentTime)}</time>;
};
// Elsewhere, render as usual:
<TimeCounter currentTime$={myStreamOfNumbers$} />;
- We call
useEffect
to set up a subscription on the incoming stream. Read more about the effect hook. - We call
useState
with an inital value and receive back a tuple of the current value and a setter. Read more about the state hook.
So minimal, still readable. Even more readable than the class version, I think.
Extracting our hook
Since Hooks are just functions, we can extract common functional patterns into our own hooks and use them in other components. I found myself writing another component which also subscribed to an incoming stream and rendered its values. What a candidate for extraction into a hook!
If we analyse the component above, we see that useState
and useEffect
are going to work roughly, if not exactly, the same for any other component. Only the types in the incoming stream differ. Here we use the type number
, but we can make our new Hook generic thanks to TypeScript’s generics.
I came up with this:
// use-stream.ts
import { useEffect, useState } from 'react';
import { Stream } from 'xstream';
const useStream = <T>(stream$: Stream<T>, initialState: T | null = null) => {
const [current, setCurrent] = useState<T>(initialState);
useEffect(() => {
const sub = stream$.subscribe({
next: setCurrent,
});
return () => sub.unsubscribe();
});
// Just return our current value, since that's the thing we're interested in
// (to render) when using this hook:
return current;
};
export default useStream;
We’ve just made our own useStream
hook by composing useEffect
and useState
!
The hook can be used like this:
import React from 'react';
import { Stream } from 'xstream';
import { formatDuration } from './libs/format-time';
import useStream from './hooks/use-stream';
interface Props {
currentTime$: Stream<number>;
}
const CurrentTimeCounter = (props: Props) => {
const currentTime: number = useStream<number>(props.currentTime$, 0);
return <time>{formatDuration(currentTime)}</time>;
};
And at last, there is beauty.