June 23rd, 2019 - 8 min read
At the moment, I'm learning Typescript (also called TS later) and I'd like to show a small example of how to use Typescript with React hooks.
If anything is not right or could be done easier / better, please let me know as I'm pretty new to it. I worked a lot with Flow.js - I think Typescript is pretty similar but also different in some details.
What I've noticed so far is that the tooling is working like a charm - Flow is really annoying on Windows (slow, server crashing, collisions of the command-line server with vs code, etc.).
With TS you're getting fast linting in VS Code and IntelliSense without complex installation.
First, I'd like to mention some good resources to learn more about Typescript & typed hooks (not complete list but should get you started):
I'm not going into every TS specific detail. I just want to explain the hook related typing. If anything is unclear please add a comment or look at the links mentioned above.
A basic understanding of hooks is required to understand the code here as I'm not describing every detail. If you need more information about Hooks please first have a look here.
The app that we're creating is a basic counter that will automatically increment every second.
If you need a useCounter
hook in your app I would use the hook from react-use. The API of the demo is pretty similar to that from react-use
.
I've picked a counter as it's not that difficult to implement and a good hook example.
I'm not going into it but you could get a basic setup with npx create-react-app counter-app --typescript
or create a parcel.js
app - I'm using Parcel but if you'd like to use CRA you can read about it here.
For the Parcel Typescript setup please have a look in the Parcel docs or the demo at the end of the post.
The following code will be used in our app - don't be overwhelmed by it. I will go into the details after the code block.
import { useState, useEffect } from 'react';
import useInterval from './useInterval';
// return Type of useCOunter
type ReturnValueUseCounter = [
number, // value
{
inc(step?: number): void;
dec(step?: number): void;
get(): number;
set(count: number): void;
reset(count?: number): void;
}
];
interface ICounter {
initialCount?: number;
onReset?: () => void;
onAfterReset?: (initialCount: number) => void;
options?: {
autoIncrementCounter: boolean;
};
}
let renderCounter = 0;
const defaultOptions = {
autoIncrementCounter: true,
};
const useCounter = ({
initialCount = 0,
onReset,
onAfterReset,
options = defaultOptions,
}: ICounter): ReturnValueUseCounter => {
const [count, setCount] = useState(initialCount);
const [state, setState] = useState({
resetTriggered: false,
});
// Event handlers
const inc = (step: number = 1) => {
setCount(count + step);
};
const dec = (step: number = 1) => {
setCount(count - step);
};
const get = () => count;
const reset = (count: number = initialCount) => {
setCount(count);
setState({ ...state, resetTriggered: true });
if (onReset) {
onReset();
}
};
// Side effects
useEffect(() => {
if (state.resetTriggered && onAfterReset) {
setState({ ...state, resetTriggered: false });
onAfterReset(initialCount);
}
}, [count]);
options.autoIncrementCounter &&
useInterval(() => setCount(count => count + 1), 1000);
renderCounter++;
console.info('render counter', renderCounter);
return [count, { inc, dec, get, set: setCount, reset }];
};
export default useCounter;
The import useInterval from './useInterval'
is a hook for creating an interval with setInterval
- I'm not going into the details but you can read a very good post about it here.
The first part is the type of the useCounter
hook's return value. I'll repeat it here so I can describe it:
type ReturnValueUseCounter = [
number, // value
{
inc(step?: number): void;
dec(step?: number): void;
get(): number;
set(count: number): void;
reset(count?: number): void;
}
];
It's an array type that expects as first element a number which is the current counter value and as second an object type of functions. It would be also possible to extract the second part into a type CounterActions
and use it in the return type.
Note:
The question mark in the function definitions e.g. inc(step?: number):void
is called the greedy operator (or optional parameters / properties) and means that this type is optional. It's possible to test the effect e.g. call the set
function without a parameter and TS will report an error for it by setting the question mark test-wise to the definition you can remove that error but take care to handle the undefined value correctly by either setting a default value or do an if-test for the value. More details about it can be found in the docs.
We could also do the typing inline with the function defintion like
const Counter = (params): [number, CounterActions] => { /* ... */ }
I'd prefer to extract the types to its definition as it's more readable. But inlining is OK if you're just having some types.
Now to the function definition of useCounter
. It's using object desctructuring with the ICounter
interface. With-out the destruct it would look like
const useCounter = (params: ICounter): ReturnValueUseCounter => { /* ... */
}
But then you would need to access the the parameters with params.intialCount
. That's why we're desctructuring the object with
const useCounter = ({
initialCount = 0,
onReset,
onAfterReset,
options = defaultOptions,
}: ICounter): ReturnValueUseCounter => { /*...*/ }
The interface defintion ICounter
is containing the intialCount
which is optional and a number. onReset
and onAfterReset
are callback functions - onReset
is called before the counter is reset and onAfterReset
is called after reset.
I don't have a real use-case for it but I played a bit with how to trigger them.
The options
are for configuring the useCounter
hook. There could be more options like roll-over to 0 at a value or enable count limiting - just to give you some ideas.
We could also use a parameter type for the initialCount
if it would be something that's always used. Then the definition would look like:
const useCounter = (initialCount: number, options: ICounter): ReturnValueUseCounter => { /* ... */ }
useEffect
is triggered every time the count
changes but the if-statement avoids calling the onAfterReset
function. Why is this needed? There is no second argument to setState
in hooks and with this, you can implement it similarly - not sure if there is a better option but it is working.
So the reset
function will set the resetTriggered
state to true and on the next render the side effect will be triggered - once it is occurred we're resetting the state so it's ready for the next call.
options.autoIncrementCounter &&
useInterval(() => setCount(count => count + 1), 1000);
useInterval
hook is starting the interval if the option is enabled.
The renderCounter
and the console.log
is just for debugging so you can see how often a re-render will happen.
The hook code above can be used in our app like this:
const [value, { inc, dec, get, set, reset }] =
useCounter({
initialCount: 10,
onReset,
onAfterReset,
});
value
is the current counter value and the desctructured functions are for triggering these actions like incrementing, decrementing etc.
You can find the demo in this Codesandbox and the souce code here.
If Codesandbox displays an error message Target container is not a DOM element
open index.tsx
and add an empty row so the page reloads - this fixed the problem for me.
Now you should know how to add type definitions to your custom hooks. But I think the best to learn more about Typescript is by reading typed code and I think I'll have a look at the code from react-use - so I can learn more about hooks & Typescript.
What could be improved in the demo? I wanted to add theming with a dark & light theme maybe I'll add this in a separate demo as it would be interesting if it's possible with a custom hook like useTheme
with styled-components
- if it's not already available.
Adding unit tests would be also great to have - I haven't checked how to test hooks.