Type-safe state modeling with TypeScript and React Hooks

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.