In the last decade, many functional programming concepts have been making their way into modern, non-functional languages. Instead of going all-in, developers can choose to gradually adopt some of the paradigms in their applications, enjoying the best of both worlds.

In this article, we are going to discuss a functional programming technique that can greatly improve the reliability of your front-end applications: making invalid states unrepresentable. Although these concepts can be applied to most frameworks and languages, we are going to focus on React applications written in TypeScript.

Our example

To start our discussion, we are going to focus on a simple login form as an example (you can find the complete code in this Codesanbox). In a nutshell, the example is composed of:

  • A LoginBox component, which contains the input fields and invokes a callback when the user clicks on Login
  • A login function, which simulates a network call to a login endpoint. If successful, this function returns a token.
  • The App component, which keeps the application state and displays the appropriate messages/components.

Login example

Here's the complete code:

import { useState } from "react";

const LoginBox = ({
  onLogin
}: {
  onLogin: (user: string, pass: string) => void;
}) => {
  const [user, setUser] = useState("");
  const [pass, setPass] = useState("");

  return (
    <div>
      <p>Insert login details (use 'test' as password for correct logins)</p>
      <label htmlFor="user">User</label>
      <input
        type="text"
        value={user}
        onChange={(e) => setUser(e.target.value)}
      />
      <label htmlFor="pass">Password</label>
      <input
        name="pass"
        type="password"
        value={pass}
        onChange={(e) => setPass(e.target.value)}
      />
      <button onClick={() => onLogin(user, pass)}>Login</button>
    </div>
  );
};

// Simulate a login request
function login(user: string, pass: string): Promise<string> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (pass === "test") {
        // Return a fake user token
        resolve("c6041ffc-5e13-4f6d-bb33-bf9d3ae20262");
      } else {
        reject(new Error("invalid password"));
      }
    }, 1000);
  });
}

export default function App() {
  const [loading, setLoading] = useState(false);
  const [userToken, setUserToken] = useState<string | undefined>(undefined);
  const [error, setError] = useState<unknown | undefined>(undefined);

  const handleLogin = async (user: string, pass: string) => {
    setLoading(true);

    // If we don't reset them here, we might have inconsistencies
    // after multiple logins
    setError(undefined);
    setUserToken(undefined);

    try {
      const newUserToken = await login(user, pass);

      setUserToken(newUserToken);
    } catch (error) {
      setError(error);
    }

    // We need to explicitly tell react that we are not loading anymore
    setLoading(false);
  };

  const handleLogout = () => {
    setUserToken(undefined);
  };

  return (
    <div className="App">
      <h1>Login Example</h1>

      {loading && <p>Loading...</p>}
      {error && <p>An error occurred: {String(error)}</p>}
      {!userToken ? (
        <LoginBox onLogin={handleLogin} />
      ) : (
        <>
          <p>You're logged in! Your user token is {userToken}</p>
          <button onClick={handleLogout}>Logout</button>
        </>
      )}
    </div>
  );
}

The problem

The example proposed above works as expected, but it's not very maintainable. The main problem lies in the fact that the different app-state fields (loading, error, userToken) are stored and updated independently.

Suppose at some point, a coworker not particularly familiar with this area of the codebase changes the handleLogin logic to fit some new requirements. As part of the process, they mistakenly forget to reset the error state with setError(undefined):

  const handleLogin = async (user: string, pass: string) => {
    setLoading(true);

    // The coworker deletes the following line
    // setError(undefined);  <- this one gets deleted
    setUserToken(undefined);

    try {
      const newUserToken = await login(user, pass);

      setUserToken(newUserToken);
    } catch (error) {
      setError(error);
    }

    // We need to explicitly tell react that we are not loading anymore
    setLoading(false);
  };

The coworker now attempts to log in and it works as expected:

Login seems to work as expected

The happy coworker then proceeds to create PR and, with a non-zero probability, the PR gets approved and goes into staging/production.

Happy ending, right? Not exactly. The application can now enter an inconsistent state when a user gets the first login wrong and the second one correct:

Login inconsistent state

As you can see, the error message is still being shown after a successful login attempt. This is happening because the setError(undefined) call has been removed, and as a result, the error message is not being reset when performing a new login attempt.

It might be easy to blame the coworker for such an oversight, but actually, the problem lies in the way the application state was originally structured. By having the error, loading and userToken states being updated independently, we made it easier to get into an inconsistent state.

Remember, the best systems are the ones that make it harder to do wrong things. In the following sections, we're going to analyze a design that could have prevented such mistakes.

In the previous example, I chose an hypothetical coworker as the person doing the mistake. I'm definitely not implying that only coworkers make mistakes, everyone does. I do plenty of mistakes on a daily basis :)

A better approach (but still not optimal)

I hear some of you screaming: "You should have used useReducer/Redux/Zustand/{random pokemon name} instead of those multiple useState!"

Indeed, in real-world scenarios, you would have likely used a more structured approach to handle the app state, but that doesn't necessarily solve the problem of invalid states.

For example, let's refactor the App code to use React's useReducer instead of multiple useState:

// This is the app state
type State = {
  loading: boolean;
  error?: unknown;
  userToken?: string;
};

const initialState: State = {
  loading: false
};

// The various actions we can use to update that state
type PendingAction = {
  type: "pending";
};

type SuccessAction = {
  type: "success";
  userToken: string;
};

type FailureAction = {
  type: "failure";
  error: unknown;
};

type LogoutAction = {
  type: "logout";
};

type Action = PendingAction | SuccessAction | FailureAction | LogoutAction;

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "pending":
      return {
        loading: true
      };
    case "success":
      return {
        loading: false,
        userToken: action.userToken
      };
    case "failure":
      return {
        loading: false,
        error: action.error
      };
    case "logout":
      return initialState;
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleLogin = async (user: string, pass: string) => {
    dispatch({ type: "pending" });

    try {
      const newUserToken = await login(user, pass);

      dispatch({ type: "success", userToken: newUserToken });
    } catch (error) {
      dispatch({ type: "failure", error });
    }
  };

  const handleLogout = () => {
    dispatch({ type: "logout" });
  };

  return (
    <div className="App">
      <h1>Login Example</h1>

      {state.loading && <p>Loading...</p>}
      {state.error && <p>An error occurred: {String(state.error)}</p>}
      {!state.userToken ? (
        <LoginBox onLogin={handleLogin} />
      ) : (
        <>
          <p>You're logged in! Your user token is {state.userToken}</p>
          <button onClick={handleLogout}>Logout</button>
        </>
      )}
    </div>
  );
}

This version improves our example on multiple fronts:

  • The handlers (handleLogin and handleLogout) don't need to know about the internal state representation anymore, but can simply dispatch actions in a declarative way.
  • It's harder to forget updating a slice of the state, as they are handled in a central place (the reducer function) instead of being scattered around handlers.

That said, despite making it harder to make the state inconsistent, it's still possible to do so.

Let's say that our product manager requests to implement a "failed attempts" counter. In a rush, we modify the failure case of the reducer to be based on the previous state:

    case "failure":
      return {
        ...state,
        error: action.error,
        failedAttempts: state.failedAttempts + 1,
      };

As a result, the "Loading..." label is still shown after a failed login attempt:

Inconsistent state

Of course, this is another silly oversight that would have been easy to spot. We forgot to set loading: false, and instead took the previous state value (true) using the spread operator ...state. Unfortunately, in real-world systems it's easy to make such mistakes when the state is made of many fields, and such oversights might not be as easy to spot.

Luckily, we can take this solution a step further. What if we let the compiler spot such mistakes? The answer is making invalid states unrepresentable.

Making invalid states unrepresentable

Let's start from the previous state representation:

type State = {
  loading: boolean;
  error?: unknown;
  userToken?: string;
};

The first question we should be asking ourselves is: what are the possible states of this system? There is no single best answer, but a possible representation could be:

  • Idle
  • Login pending
  • Login success
  • Login failure

If the idle state doesn't make sense to you, keep in mind we are trying to model all the possible states of this system. Idle represents the state of the system before the user makes a login attempt.

Then, we can think about which fields and values those states can have:

  • Idle
    • loading = false
  • Login pending
    • loading = true
  • Login success
    • loading = false
    • userToken = string
  • Login failure
    • loading = false
    • error = Error/unknown/whatever is relevant

At this point, we can translate these states into code using Typescript's Discriminated unions (also known as Algebraic Data Types):

type State =
  | {
      type: "idle";
      loading: false;
    }
  | {
      type: "pending";
      loading: true;
    }
  | {
      type: "success";
      loading: false;
      userToken: string;
    }
  | {
      type: "failure";
      loading: false;
      error: unknown;
    };

Note how for each state, we are explicitly specifying which fields are present and, for some of them, even their value. Discriminated unions give us two superpowers:

  • We don't need to add optional fields everywhere, nor do we need to check for undefined values. If a state has type = "success" then Typescript guarantees that the userToken field will be available. In the same way, we won't be able to access the userToken if the state is failure
// This compiles correctly
if (state.type === "success") {
  console.log(state.userToken)
}

// This doesn't (and it's good!)
if (state.type === "failure") {
  console.log(state.userToken)
}
  • We won't be able to set inconsistent states in the reducer

Updating our previous example with the new approach, we get:

type State =
  | {
      type: "idle";
      loading: false;
    }
  | {
      type: "pending";
      loading: true;
    }
  | {
      type: "success";
      loading: false;
      userToken: string;
    }
  | {
      type: "failure";
      loading: false;
      error: unknown;
    };

const initialState: State = {
  type: "idle",
  loading: false
};

type PendingAction = {
  type: "pending";
};

type SuccessAction = {
  type: "success";
  userToken: string;
};

type FailureAction = {
  type: "failure";
  error: unknown;
};

type LogoutAction = {
  type: "logout";
};

type Action = PendingAction | SuccessAction | FailureAction | LogoutAction;

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "pending":
      return {
        type: "pending",
        loading: true
      };
    case "success":
      return {
        type: "success",
        loading: false,
        userToken: action.userToken
      };
    case "failure":
      return {
        ...state,
        type: "failure",
        loading: false,
        error: action.error
      };
    case "logout":
      return initialState;
  }
}

export default function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const handleLogin = async (user: string, pass: string) => {
    dispatch({ type: "pending" });

    try {
      const newUserToken = await login(user, pass);

      dispatch({ type: "success", userToken: newUserToken });
    } catch (error) {
      dispatch({ type: "failure", error });
    }
  };

  const handleLogout = () => {
    dispatch({ type: "logout" });
  };

  return (
    <div className="App">
      <h1>Login Example</h1>

      {state.loading && <p>Loading...</p>}
      {state.type === "failure" && (
        <p>An error occurred: {String(state.error)}</p>
      )}
      {state.type !== "success" ? (
        <LoginBox onLogin={handleLogin} />
      ) : (
        <>
          <p>You're logged in! Your user token is {state.userToken}</p>
          <button onClick={handleLogout}>Logout</button>
        </>
      )}
    </div>
  );
}

At this point, Typescript will make sure that the state is always in a consistent state. For example, the previous mistake (forgetting loading: false in the failure case due to the spread operator) won't compile:

Typescript fails to compile an invalid state

We made invalid states unrepresentable šŸ˜Ž

Note: You might be wondering why we added the loading field to all states. The reason is that I thought it would make the example easier to understand. But in this simple scenario, we could drop the loading field entirely and only show the loading message when the state is pending (state.type === 'pending').

Final thoughts

All right! We started from a problematic example and gradually improved the way our state is represented, making the app more robust along the way.

It's important to consider these techniques as an additional tool you have at your disposal rather than absolute truths. There might be cases where these techniques are impractical for several reasons. As always, use the right tool for the job!

If you're interested, you can find the complete code for our final solution in this Codesandbox.