Javier Casas

A random walk through computer science

Typechecking state transitions in a React/Redux app

Following the ideas of applying types to frontend development, today we arrive to typed state transitions. This is a technique that allows putting the typechecker to work in order to control the complexity of a React/Redux application.

A sample React/Redux application

One of the most basic tasks that a frontend developer is expected to do every day is fetching and displaying data from a server. It's a very basic, but common case, yet at the same time it's too easy to introduce errors in it. To understand the problem, let's start with the requirements:

  • When starting the application, it will download the last data from https://uberserver.example.com/api/users.
  • While this data is being downloaded, a loading animation (a spinner) will be shown.
  • When all the data has been downloaded, it will be shown in a table with rows and columns following a design the designer will provide later.

Thankfully, these requirements are good enough for us to sketch a state machine.

State machines

In comparison with the good olden days (emphasis mine), current frontend is harder, because it's full of state on the browser. Whereas in a non Single Page Application you get to reset everything on each request, thus starting with new and clean state on each request, in a Single Page Application you don't have this benefit, and therefore you have to manage your state in a more careful way. In order to manage this state in a better way, let's study a little bit about state machines.

A state machine is a theoretical device that has states (usually drawn as circles with annotations inside the circle) that are connected by transitions (usually drawn as arrows from a state to another state). At any point in time, the machine is in a single state, and can move to other state following a transition. These state changes are triggered by external or internal conditions (such as receiving data from the server, a button receiving a click event, a timeout expiring, and many others).

All stateful applications are state machines under the hood, regardless of if the developer wants to implement a state machine or not. So, at the end of the day, we have two kinds of stateful applications:

  • The ones where the state is being thoughfully managed, probably applying some state machine theory.
  • The ones where the state is not being managed properly.

Let's try to do it better than the average by properly managing our state.

Enumerating states and transitions

Unfortunately, enumerating states is not always direct and easy, and can be confusing some times. You can read about it from lots of online resources on Moore and Mealy state machines used in electronics, for example, here. A basic approach for frontend development can be derived from constructing a different state each time the interface changes significatively. For our example, we can start sketching some states:

  • An initial state that the application boots into.
  • A "loading" state that shows the spinning widget.
  • A state where the data is being shown.

With states, we also have to sketch transitions:

  • A transition from the initial state to the loading state, that also triggers starting download data from the server.
  • A transition from the loading state to the showing state, that is triggered when the data download is complete.

Basic state machine

Typing states and actions

Let's use the typesystem to put safety in this tiny state machine before it grows and becomes unmanageable. First, types for the states:

type InitialState = {
  type: "INITIAL";
}

type LoadingState = {
  type: "LOADING";
}

type ShowDataState = {
  type: "SHOW_DATA";
  data: User[];
}

type State = InitialState | LoadingState | ShowDataState;

We have two transitions, which tell us we should probably use two different actions to trigger them:

type StartLoading = {
  type: "START_LOADING";
}

type DataArrived = {
  type: "DATA_ARRIVED";
  data: User[];
}

type Action = StartLoading | DataArrived;

Typing transitions

Finally, we can use types to ensure the transitions make sense, and to tighten even more the types of the system. First we start with the transition from InitialState to LoadingState:

function startLoading(s: InitialState, a: StartLoading): LoadingState {
  return {
    type: "LOADING";
  };
}

Even though this function doesn't make use of the second argument, this argument being there is very important, because it makes the function type wonderfully descriptive. It means: if you are in the InitialState, and you receive a StartLoading action, then you are allowed to transition to LoadingState. The type is so wonderfully descriptive that we could get away with not naming the function, as the type is incredibly specific and descriptive.

For the second transition we are going to use the same trick:

function dataArrived(s: LoadingState, a: DataArrived): ShowDataState {
  return {
    type: "SHOW_DATA",
    data: a.data
  };
}

The general trick is creating each transition as a function that receives both the previous state and the action to be performed, and returns the new state, using types to minimize the possible values acceptable. We accept even actions that may not be used so that the typesystem can enforce constraints for us.

At this point, you should be asking yourself why do we go to such great lengths to separate every transition into functions, and put types to everything? The answer is way more than some kind of fuzzy "type safety". Let's write the reducer just to see how the types guide us into the right implementation. We start with a basic reducer with annotated types:

function myReducer(state: State, action: Action): State {
  // Type error: myReducer is not returning a "State"
}

For this to be a reducer, we have to do something depending on the State and the Action. Let's go first with the State:

function myReducer(state: State, action: Action): State {
  if (state.type === "blah") { // Type error: "blah" is not of type "INITIAL" | "LOADING" | "SHOW_DATA"
  }
}

The typechecker is already helping us. Not only it tells us that "blah" is not a valid type of state, but also tells us which real state types are valid. Let's expand the if into a multiway-if with a condition for each possible value:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
  } else if (state.type === "LOADING") {
  } else { // state.type === "SHOW_DATA"
  }
}

This looks more like a prototypical reducer. But we are still not returning a new state in each of the branches, so let's work that part.

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    return startLoading(state, action);  // Type error: action is of type Action, but the function accepts a StartLoading
  } else if (state.type === "LOADING") {
  } else { // state.type === "SHOW_DATA"
  }
}

Aha! The typechecker just caught us forgetting something. We haven't proved that the action is actually a StartLoading. For what we know, another action could come in and we would have to do something different! Let's also check the action:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "blah") { // Type error: "blah" is not of type "START_LOADING" | "DATA_ARRIVED"
    }
  } else if (state.type === "LOADING") {
  } else { // state.type === "SHOW_DATA"
  }
}

Again, the types guide us towards the right solution. We need to discriminate both possible actions, and we can finally use the startLoading function:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "START_LOADING") {
      // At this point we proved to the typechecker that
      //   * state is InitialState
      //   * action is StartLoading
      // So we are allowed to call startLoading
      return startLoading(state, action);
    } else { // action.type === "DATA_ARRIVED"
      // Uuuh, what are we supposed to do if a DataArrived action arrives when we are on the InitialState?
      // It should be impossible to happen, but the types ask us to actually confirm what we will do.
      // This may be open for discussion, but for now let's log the error and do nothing.
      console.error("Somehow DATA_ARRIVED on the INITIAL state. This should be impossible.");
      return state;
    }
  } else if (state.type === "LOADING") {
  } else { // state.type === "SHOW_DATA"
  }
}

The reducer for the InitialState is done, let's work on the reducer for the LoadingState. Again, triggering a type error with type = "blah" makes the typechecker into telling us what possible actions do we have to check:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    ...
  } else if (state.type === "LOADING") {
    if (action.type === "blah") { // Type error: "blah" is not of type "START_LOADING" | "DATA_ARRIVED"
    }
  } else { // state.type === "SHOW_DATA"
  }
}

And, again, we have to prove that our action is of type "DATA_ARRIVED" before we can call the dataArrived function:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    ...
  } else if (state.type === "LOADING") {
    if (action.type === "DATA_ARRIVED") {
      return dataArrived(state, action);
    } else { // action.type === "START_LOADING"
      console.error("Somehow we got a START_LOADING on the LOADING state. This should be impossible.");
      return state;
    }
  } else { // state.type === "SHOW_DATA"
  }
}

Finally, the "SHOW_DATA" reducer:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    ...
  } else if (state.type === "LOADING") {
    ...
  } else { // state.type === "SHOW_DATA"
    console.error("Somehow we got the action", action.type, "in the SHOW_DATA state. No actions are expected in this state");
    return state;
  }
}

At this point, the reducer almost wrote itself, because the types were so specific that pretty much defined the implementation.

The UI

The UI almost writes itself from the state. Let's create renderers for each possible state:

const renderInitialState: React.SFC<InitialState> = (props) => {
  return <div />;  // Empty div on the initial state, because we are about to transition to the Loading state
}

const renderLoadingState: React.SFC<LoadingState> = (props) => {
  return <Spinner />;
}

const renderShowDataState: React.SFC<ShowDataState> = (props) => {
  return (
    <table>
      <tbody>
        {props.data.map(user =>
          <tr>
            <td>{user.name}</td>
            <td>{user.age}</td>
          </tr>
        }
      </tbody>
    </table>
  );
}

Each render function is simple and guided by the types. The typechecker allows us to use the props.data : User[] array only on renderShowDataState, because that's the only case we may have it. We finally wrap everything with a general render function, that almost writes itself using the types:

const render: React.SFC<State> = (props) => {
  if (props.type === "INITIAL") {
    return renderInitialState(props);
  } else if (props.type === "LOADING") {
    return renderLoadingState(props);
  } else { // props.type === "SHOW_DATA"
    return renderShowDataState(props);
  }
}

We finally add enough boilerplate to make an app, and we are almost ready to go!

interface DataVisualizatorActions {
  readonly startFetching: () => void;
  readonly dataArrived: (users: User[]) => void;
}

class DataVisualizator extends React.Component<State & DataVisualiztorActions> {
  componentWillMount() {
    this.props.startFetching();
    doServerRequest().next(users => this.props.dataArrived(users));
  }

  render() {
    if (props.type === "INITIAL") {
      return renderInitialState(props);
    } else if (props.type === "LOADING") {
      return renderLoadingState(props);
    } else { // props.type === "SHOW_DATA"
      return renderShowDataState(props);
    }
  }
}

const mapStateToProps = (state: State) => state;

const mapDispatchToProps = (dispatch: Dispatch) => ({
  startFetching: () => dispatch({type: "START_LOADING"}),
  dataArrived: (users) => dispatch({type: "DATA_ARRIVED", data: users})
});

const ConnectedDataVisualizator = connect(
  mapStateToProps,
  mapDispatchToProps
)(DataVisualizator);

const store = createStore(
  myReducer,
  { type: "INITIAL" }
);

const App = () => {
  return (
    <Provider store={store}>
      <ConnectedDataVisualizator />
    </Provider>
  );
};

render(<App />, document.getElementById("root"));

Requirements changed!

Inevitably, requirements change, because every project changes requirements every now and then. After running this app in production for a while, we found an issue: sometimes the connection to the server fails, and it would be nice to show an error in this case.

Let's see if the types are helpful under this requirement change.

Show an error if the connection to the server fails

In this case, we want to show a different page that says "Something went wrong". Because it's a different page, this suggests us to create a new state for this page. So let's add another entry to State:

Improved state machine

type ErrorState = {
  type: "ERROR";
  message: string;
}

type State = InitialState | LoadingState | ShowDataState | ErrorState;

After adding this, the typechecker throws some errors! We shouldn't have broken this much with so tiny change, shouldn't we? Let's check one of these errors:

class DataVisualizator extends React.Component<State & DataVisualiztorActions> {
  ...
  render() {
    if (props.type === "INITIAL") {
      return renderInitialState(props);
    } else if (props.type === "LOADING") {
      return renderLoadingState(props);
    } else { // props.type === "SHOW_DATA"
      return renderShowDataState(props); //  Error: props is of type ShowDataState | ErrorState, but it's being used as ShowDataState
    }
  }
}

Well, the system caught us there, so we have to add another else if in order to separate the new case. Also, the helpful comment we wrote on the else branch (// props.type === "SHOW_DATA") is hopelessly obsolete by now. It should say // props.type === "SHOW_DATA" | "ERROR". But we are not going to fall into this mistake again, right? So let's erase it and turn it into an exhaustiveness check so the typechecker tells us next time we have to update it:

function assertNever(x: never): never {
  throw new Error("The impossible has happened:" + x);
}

class DataVisualizator extends React.Component<State & DataVisualiztorActions> {
  ...
  render() {
    if (props.type === "INITIAL") {
      return renderInitialState(props);
    } else if (props.type === "LOADING") {
      return renderLoadingState(props);
    } else if (props.type === "SHOW_DATA") {
      return renderShowDataState(props);
    } else {
      assertNever(props);
    }
  }
}

Now the typechecker tells us that the assertNever is wrong, because it should be of type never, but it's actually of type ErrorState. This is because we forgot to handle this state, so let's do it:

class DataVisualizator extends React.Component<State & DataVisualiztorActions> {
  ...
  render() {
    if (props.type === "INITIAL") {
      return renderInitialState(props);
    } else if (props.type === "LOADING") {
      return renderLoadingState(props);
    } else if (props.type === "SHOW_DATA") {
      return renderShowDataState(props);
    } else if (props.type === "ERROR") {
      return renderError(props);  // This function is defined somewhere else
    } else {
      assertNever(props);
    }
  }
}

Now this part compiles fine, but we have some bugs in the reducer because we didn't use exhaustiveness checks. Let's change it:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "START_LOADING") {
      return startLoading(state, action);
    } else { // action.type === "DATA_ARRIVED"
      console.error("Somehow DATA_ARRIVED on the INITIAL state. This should be impossible.");
      return state;
    }
  } else if (state.type === "LOADING") {
    if (action.type === "DATA_ARRIVED") {
      return dataArrived(state, action);
    } else { // action.type === "START_LOADING"
      console.error("Somehow we got a START_LOADING on the LOADING state. This should be impossible.");
      return state;
    }
  } else if (state.type === "SHOW_DATA") {
    console.error("Somehow we got the action", action.type, "in the SHOW_DATA state. No actions are expected in this state");
    return state;
  } else {
    assertNever(state);  // Exhaustiveness check here
  }
}

Again, the exhaustiveness check fails because we didn't check ErrorState. Also, we just spotted a bunch of else branches that check the action.type, and they look like prime candidates for more exhaustiveness checks. Let's also change them:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "START_LOADING") {
      return startLoading(state, action);
    } else if(action.type === "DATA_ARRIVED")
      console.error("Somehow DATA_ARRIVED on the INITIAL state. This should be impossible.");
      return state;
    } else {
      assertNever(action);  // Exhaustiveness check here
    }
  } else if (state.type === "LOADING") {
    if (action.type === "DATA_ARRIVED") {
      return dataArrived(state, action);
    } else if(action.type === "START_LOADING")
      console.error("Somehow we got a START_LOADING on the LOADING state. This should be impossible.");
      return state;
    } else {
      assertNever(action);  // Exhaustiveness check here
    }
  } else if (state.type === "SHOW_DATA") {
    console.error("Somehow we got the action", action.type, "in the SHOW_DATA state. No actions are expected in this state");
    return state;
  } else if (state.type === "ERROR") {
    console.error("Somehow we got the action", action.type, "in the ERROR state. No actions are expected in this state");
    return state;
  } else {
    assertNever(state);  // Exhaustiveness check here
  }
}

We finally need to detect the error when fetching the data and throw it as an Action:

interface DataVisualizatorActions {
  readonly startFetching: () => void;
  readonly dataArrived: (users: User[]) => void;
  readonly errorHappened: (error: string) => void;
}


class DataVisualizator extends React.Component<State & DataVisualiztorActions> {
  componentWillMount() {
    this.props.startFetching();
    doServerRequest()
      .next(users => this.props.dataArrived(users))
      .catch(() => this.props.errorHappened("Something went wrong"));
  }

  render() {
    ...
  }
}

const mapDispatchToProps = (dispatch: Dispatch) => ({
  startFetching: () => dispatch({type: "START_LOADING"}),
  dataArrived: (users) => dispatch({type: "DATA_ARRIVED", data: users}),
  errorHappened: (error) => dispatch({type: "ERROR_HAPPENED", message: error})  // Type error: "ERROR_HAPPENED" is not of type "START_LOADING" | "DATA_ARRIVED"
});

As we fixed more details, the typesystem helpfully told us that "ERROR_HAPPENED" is not a valid action. Which suggests we should add it as an action:

type ErrorHappened = {
  type: "ERROR_HAPPENED";
  message: string;
}

type Action = StartLoading | DataArrived | ErrorHappened;

After making this change, the typechecker lights up in red in the reducer:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "START_LOADING") {
      return startLoading(state, action);
    } else if(action.type === "DATA_ARRIVED")
      console.error("Somehow DATA_ARRIVED on the INITIAL state. This should be impossible.");
      return state;
    } else {
      assertNever(action);  // Type error: action is of type ErrorHappened, not never
    }
  } else if (state.type === "LOADING") {
    if (action.type === "DATA_ARRIVED") {
      return dataArrived(state, action);
    } else if(action.type === "START_LOADING")
      console.error("Somehow we got a START_LOADING on the LOADING state. This should be impossible.");
      return state;
    } else {
      assertNever(action);  // Type error: action is of type ErrorHappened, not never
    }
  } else if (state.type === "SHOW_DATA") {
    console.error("Somehow we got the action", action.type, "in the SHOW_DATA state. No actions are expected in this state");
    return state;
  } else if (state.type === "ERROR") {
    console.error("Somehow we got the action", action.type, "in the ERROR state. No actions are expected in this state");
    return state;
  } else {
    assertNever(state);
  }
}

Right, we just added a new type of Action, but we haven't bothered checking it on the reducer! Thank you typesystem! Let's do some changes:

function myReducer(state: State, action: Action): State {
  if (state.type === "INITIAL") {
    if (action.type === "START_LOADING") {
      return startLoading(state, action);
    } else if(action.type === "DATA_ARRIVED" || action.type === "ERROR_HAPPENED")
      console.error("We got a", action.type, "on the INITIAL state. This should be impossible.");
      return state;
    } else {
      assertNever(action);
    }
  } else if (state.type === "LOADING") {
    if (action.type === "DATA_ARRIVED") {
      return dataArrived(state, action);
    } else if (action.type === "ERROR_HAPPENED") {
      return errorHappened(state, action);  // Error: errorHappened is not defined
    } else if(action.type === "START_LOADING")
      console.error("We got a", action.type, "on the LOADING state. This should be impossible.");
      return state;
    } else {
      assertNever(action);
    }
  } else if (state.type === "SHOW_DATA") {
    console.error("Somehow we got the action", action.type, "in the SHOW_DATA state. No actions are expected in this state");
    return state;
  } else if (state.type === "ERROR") {
    console.error("Somehow we got the action", action.type, "in the ERROR state. No actions are expected in this state");
    return state;
  } else {
    assertNever(state);
  }
}

We finally need to define errorHappened with good types and we are ready to go.

function errorHappened(s: LoadingState, a: ErrorHappened): ErrorState {
  return {
    type: "ERROR",
    message: a.message
  };
}

Conclusion

We saw an example, step by step, on how we can construct a basic web application protected with good types. Along the way we figured out how the typechecker can make our lives easier by telling us where we have to refactor. We can define state transitions with types in a way so good that you almost don't require good function names anymore. We also found that types can be a form of documentation that doesn't get obsolete, as comments tend to. Finally, good types with exhaustiveness checks are great, and can make refactoring easy and painless by pinpointing how many of the assumptions are affected after we change a type.

Back to index
Copyright © 2018-2023 Javier Casas - All rights reserved.