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 Mon, September 07, 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.
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:
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:
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.
Without further ado, let's go through it based on a little example, a login form.
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.
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.
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?
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.
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.
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.