The power of React Hooks allows us to manage reusable and composable state in our React components, without the need for class components or global state (such as Redux). Combining that with the flexibility of TypeScript interfaces, there are several different methods we can use to model our component state and context in a type-safe way.
In this post I will illustrate some of the benefits to using typescript strict mode with React Hooks and hope that you will consider using it for your TypeScript+React projects. Seemingly trivial things such as avoiding the Any type and ensuring you always define a return type will allow the type system to catch your mistakes before they can become runtime errors. This is especially helpful when debugging state management in React, where tracking down runtime errors can be particularly challenging.
Our goal will be to explore some examples of state management using React Hooks
(specifically useState
and useContext
) while maintaining strict typing using
TypeScript. In addition to providing type safety to our hooks and state data,
types act as living documentation which provide clarity around how the
application’s state is managed.
Let’s start with a very simple example of using React Hooks to manage some state for a user profile:
const ProfilePage = (): ReactElement => {
const [firstName] = useState("Foo");
const [lastName] = useState("Bar");
const [title] = useState("Software developer");
return (
<dl>
<dt>First name:</dt> <dd>{firstName}</dd>
<dt>Last name:</dt> <dd>{lastName}</dd>
<dt>Title:</dt> <dd>{title}</dd>
</dl>
);
};
In the above example we have three calls to useState
, which can be simplified
into a single hook which uses an object to hold the values. In doing this, we’ll
also define an interface for that object so that we can enforce the type model
of our state:
interface Profile {
firstName: string;
lastName: string;
title: string;
}
const ProfilePage = (): ReactElement => {
const [profile] = useState<Profile>({
firstName: "Foo",
lastName: "Bar",
title: "Software developer",
});
return ( . . . );
};
One immediate benefit of creating an interface for Profile
is that we now have
a way to refer to what our state looks like. The object we pass to
useState<Profile>
could come from anywhere, and we would know it was a
Profile
.
We can also use this interface to make a type-safe custom hook that accepts overrides for the default values:
interface ProfileState {
profile: Profile;
setProfile: React.Dispatch<React.SetStateAction<Profile>>;
}
export const useProfile = (overrides?: Partial<Profile>): ProfileState => {
const defaultProfile: Profile = {
firstName: "Foo",
lastName: "Bar",
title: "Software developer",
};
const [profile, setProfile] = useState<Profile>({
...defaultProfile,
...overrides,
});
return { profile, setProfile };
};
const ProfilePage = (): ReactElement => {
const { profile } = useProfile({
title: "Designer",
});
return (. . .);
};
In this example we define a custom react hook useProfile
which accepts a
Partial<Profile>
as an argument. If you’re not familiar with the TypeScript
Partial
utility type, it defines a new type from an existing type which
represents all possible subsets of the existing type. So Partial<Profile>
allows us to pass any combination of the keys from the Profile
interface
(firstName
, lastName
, title
), or none at all.
In the definition of useProfile
we define a set of static defaults, which we
can conveniently override by passing in an optional overrides
argument. Because
we’ve codified all our state as either Profile
or Partial<Profile>
, we can
be confident that both the implementation of the useProfile
hook and its
callers are passing around the right keys and values.
We also define an interface ProfileState
so that we can explicitly declare the
return type of our useProfile
hook. This has the benefit that if we ever
compose this hook within another hook or use it inside a context, we have a
concrete way to refer to its return value.
If we want to share this bucket of state across multiple components, perhaps the
most obvious way to go about it would be to reimplement our hook with
useContext
:
const defaultProfile: Profile = {
firstName: "Foo",
lastName: "Bar",
title: "Software developer",
};
const defaultProfileState: ProfileState = {
profile: defaultProfile,
setProfile: (): void => {},
};
export const ProfileContext = createContext<ProfileState>(defaultProfileState);
export const useProfileContext = (): ProfileState => {
return useContext(ProfileContext);
};
const ProfilePage = (): ReactElement => {
const { profile } = useProfileContext();
return (. . .);
};
In the above example, we have renamed our hook from useProfile
to
useProfileContext
, and implemented it in terms of react’s useContext
hook.
We also moved the default profile state into a constant, but otherwise, our
component implementation remains mostly the same. The logic which was previously
in our useProfile
hook will move into our context provider:
interface ProfileContextProviderProps {
defaults?: Partial<Profile>;
children?: ReactNode;
}
export const ProfileContextProvider = (
props: ProfileContextProviderProps,
): ReactElement => {
const [profile, setProfile] = useState<Profile>({
...defaultProfile,
...props.defaults,
});
return (
<ProfileContext.Provider
value={{
profile,
setProfile,
}}>
{props.children}
</ProfileContext.Provider>
);
};
And finally, now that we have defined our context provider, we can use it in our app:
const App = (): ReactElement => {
const defaults: ProfileContextProviderProps = {
title: "Designer",
};
return (
<ProfileContextProvider defaults={defaults}>
<ProfilePage />
</ProfileContextProvider>
);
};
At the expense of some type verbosity, we have created a strongly typed model of our React component state. We can be confident that the values that make it into our state are of the types we expect to be there, and we have a self-documenting and easy way to modify the interface for our custom hook.