Thomas Bandt

Pragmatic MVU With React And TypeScript

With the introduction of hooks, React got some compelling new instruments – including useReducer, which can be used to implement Model-View-Update within a React component.

Published on Monday, 07 September 2020

While probably intended to "hook up" developers experienced in working with Redux, useReducer at the same time opens the door for implementing The Elm Architecture, aka Model-View-Update (MVU) quickly and pragmatically.

The Core Idea

If you don't know what MVU is and want to learn more first, check out my article on how it works. If you are impatient, I can offer you a shortcut:

MVU

From my perspective, the whole thing's essential idea is to make changes to the state explicit and easy to reason about. That is achieved through:

  1. Data is flowing uni-directionally.
  2. Having a view that is a function of the state (model).
  3. Having an update function that is the only place where state changes.
  4. Communicating through messages.

It is fair to argue that 1. and 2. are already core principles of React itself. However, useReducer adds 3. and 4. to the game.

A Practical Example

Without further ado, let's go through it based on a little example, a login form.

What Are The Requirements?

  1. There are a user name and a password field, and a submit button.
  2. The button must only be enabled when the user name and password are provided.
  3. When the button is clicked, some work shall be done (e.g., validation/network request).

The State

interface State {
  userName: string;
  password: string;
  isValid: boolean;
}

const initialState: State = {
  userName: "",
  password: "",
  isValid: false,
};

That's as unspectacular as necessary, as the core information we are working with is defined at a central place. So whenever you want to access the user name or password, you don't need to go to the form itself.

The Messages

type UserNameChangedMsg = {
  type: "UserNameChangedMsg";
  userName: string;
};

type PasswordChangedMsg = {
  type: "PasswordChangedMsg";
  password: string;
};

type Msg = UserNameChangedMsg | PasswordChangedMsg;

While the definition of messages is not as lean as in other languages (in F#, this would have been a three-liner), TypeScript, fortunately, supports Tagged Union Types, which do the job.

The View

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

  return (
    <div>
      <input
        type="text"
        placeholder="User name"
        defaultValue={state.userName}
        onChange={(e) =>
          dispatch({ type: "UserNameChangedMsg", userName: e.target.value })
        }
      />
      <input
        type="password"
        placeholder="Password"
        defaultValue={state.password}
        onChange={(e) =>
          dispatch({ type: "PasswordChangedMsg", password: e.target.value })
        }
      />
      <button disabled={!state.isValid} onClick={() => signIn(state)}>
        Sign in
      </button>
    </div>
  );
}

Now things are getting more interesting. Let's go through it:

const [state, dispatch] = useReducer(update, initialState);

There it is, our useReducer hook. Calling it returns two things: The current state and a dispatch function.

The state can be used to render the view depending on its properties. For example, as required, the sign-in button is only enabled when state.isValid is set to true.

The dispatch function is used to "fire" messages: Whenever the user changes the user name or the password, the corresponding message is being dispatched. Note that it contains the element's value as its payload, for example, in the case of the password:

onChange={(e) =>
  dispatch({ type: "PasswordChangedMsg", password: e.target.value })
}

Now, two questions come up: What happens when a message is being dispatched? And how does state.isValid ever become true?

The Update Function

function validate(state: State): boolean {
  return state.userName.length > 0 && state.password.length > 0;
}

function update(state: State, msg: Msg) {
  switch (msg.type) {
    case "UserNameChangedMsg": {
      const newState = { ...state, userName: msg.userName };
      return { ...newState, isValid: validate(newState) };
    }
    case "PasswordChangedMsg": {
      const newState = { ...state, password: msg.password };
      return { ...newState, isValid: validate(newState) };
    }
  }
  return assertUnreachable(msg);
}

Here it is, the U of MVU, our update function. Its signature is defined by the useReducer hook: It accepts the latest known state and a message it is supposed to process.

Based on some magic of the TypeScript compiler, it is possible to switch through the messages based on the discriminant property type, which all of them provide.

Depending on the message type, we now know what should be changed, userName or password, and so we do. We also set the isValid property, which becomes only true when both the user name and the password contain at least one character.

Side note: The whole thing is exhaustive, which is achieved by the little helper function assertUnreachable():

function assertUnreachable(x: never): never {
  throw new Error("Didn't expect to get here");
}

Whenever our tagged union Msg gets a new case, the compiler will notice, and the build will fail – until we add that case to the switch.

Commands?

If you already know about MVU, you might have noticed that there are no commands yet. And there won't be any – the useReducer hook doesn't know about the concept of commands. That's why I call it a pragmatic approach.

But that's not the end of the world as it is possible to work around it for most cases. For example, when our sign-in button is enabled and gets clicked, we call another function and pass the current state:

function signIn(state: State) {
  // Do something here ...
}

We could validate the credentials, make network requests, or do whatever we like. And if the result would be a change of our state, we could pass the dispatch function and dispatch a message from within here.

Conclusion

The example may seem relatively trivial, even too insignificant to use that MVU approach. But if you chose to go down that route, it will come with all the benefits listed above – even for such a relatively simple component.

And once you follow the pattern in more and more components of your application, you will notice that behavior reasoning will become much more straightforward.

PS: See here for the complete code of the component.

What do you think? Drop me a line and let me know!