Skip to content

Switch / Case

In the last example, our reducer was comprised of a bunch of if/else statements:

function reducer(todos, action) {
if (action.type === 'create-todo') {
return /* ✂️ */;
} else if (action.type === 'toggle-todo') {
return /* ✂️ */;
} else if (action.type === 'delete-todo') {
return /* ✂️ */;
}
}

This works perfectly well, but there's a more common convention when it comes to Redux: switch/case statements.

Here's what it looks like, if we rewrite this reducer to use a switch/case:

function reducer(todos, action) {
switch (action.type) {
case 'create-todo': {
return /* ✂️ */;
}
case 'toggle-todo': {
return /* ✂️ */;
}
case 'delete-todo': {
return /* ✂️ */;
}
}
}

Inside the switch() parens, we place some sort of JS expression that can have multiple acceptable values. In this case, we want to switch between different branches of logic depending on the value of action.type.

Each case is given a matching value. And so, if action.type is equal to "delete-todo", it skips over the first 2 case statements, and the third one is executed.

Functionally, it's the exact same thing as our if/else combo above. It's a different syntactical option for the same result.

But, it happens to be an incredibly common convention. Pretty much every reducer I've seen in the wild has a switch/case in it, checking the value of action.type.

There are a couple of gotchas to be aware of with switch/case.

Added brackets

Strictly speaking, the example I showed above has some unnecessary grammar: we don't need brackets around each case:

switch (status) {
case 'loading': // <-- No "{"!
const showSpinner = !action.invisible;
return {
loading: true,
showSpinner,
};
case 'ready':
return {
loading: false,
data: action.data,
}
}

By adding the squiggly brackets, we create a scope for each case. That means that any variables created within the case will be scoped to that particular case.

Without the squigglies, we can run into issues like this:

switch (status) {
case 'loading':
const showSpinner = !action.invisible;
return {
loading: true,
showSpinner,
};
case 'ready':
// 🚫 Uncaught SyntaxError:
// Identifier 'showSpinner' has already been declared
const showSpinner = false;
return {
loading: false,
showSpinner,
data: action.data,
}
}

We declare a showSpinner variable in the first case, but because the entire switch is one big scope, we get an error when we try and create a variable with the same name in another case.

When we add the squiggly brackets, this problem is solved:

switch (status) {
case 'loading': {
const showSpinner = !action.invisible;
return {
loading: true,
showSpinner,
};
}
case 'ready': {
// ✅ No problem!
const showSpinner = false;
return {
loading: false,
showSpinner,
data: action.data,
}
}
}

In my mental model, each case is its own branch, its own mini environment. By adding squiggly brackets, we align reality to this mental model. Any variables created with let or const will be scoped to its branch.

Fall-through

So there's one totally bewildering thing about switch/case.

Take a look at the following snippet. What do you expect to be logged to the console?

const n = 2;
switch (n) {
case 1: {
console.log(1);
}
case 2: {
console.log(2);
}
case 3: {
console.log(3);
}
case 4: {
console.log(4);
}
}

Well, n is equal to 2, and so I'd expect the number 2 to be logged, right?

Here's what actually gets logged:

2
3
4

By default, once we've found a matching case, the code within that case will be run… along with every subsequent case!

In order to avoid this behaviour, we need to end each case with the special keyword break:

switch (n) {
case 1: {
console.log(1);
break;
}
case 2: {
console.log(2);
break;
}
case 3: {
console.log(3);
break;
}
case 4: {
console.log(4);
break;
}
}

When the switch/case is within a function, like it is with useReducer, we have another option: we can bail out of the entire function, using the return keyword:

function reducer(todos, action) {
switch (action.type) {
case 'create-todo': {
return /* ✂️ */;
}
case 'toggle-todo': {
return /* ✂️ */;
}
case 'delete-todo': {
return /* ✂️ */;
}
}
}

We aren't using break here, but it's fine, since we abort the entire reducer() function call. Instead of halting execution of the switch/case, we halt execution of the entire function!

I wanted to bring this up for two reasons:

  1. Sometimes, when I'm hacking on the business logic, I want to run the code before it's finished, to log a value and see if I'm on the right track. If I haven't yet added the return statement to my work-in-progress reducer, I might see some very peculiar behaviour!
  2. In a couple of lessons, we'll talk about Immer. When working with Immer, the break keyword is more common.

Default cases

With switch statements, it's possible to add a default case:

switch (fruit) {
case 'apple': {
console.log('Keep the doctor away');
break;
}
case 'banana': {
console.log("You're the top banana!");
break;
}
default: {
console.log('Unrecognized fruit');
}
}

If none of the cases match, the code inside the default block will run.

In Redux, default cases are necessary, due to a quirk of how state gets initialized. With useReducer, however, we don't typically need a default case.

Frustratingly, certain ESLint 👀 configurations will throw a warning if the switch statement doesn't have a default case. The rule in question is default-case. I recommend disabling this rule when working with useReducer.