← Back
react react-tapas

React Tapas 8: Reducers part 2

The standard solution to encapsulate complex business logic in object oriented programming is writing a class:

class Car {
constructor() {
this.color = "red";
this.size = 5;
this.model = "au-3423";
}

setColor(color) {
this.color = color;
}

setSize(size) {
if (this.color !== "red" && size > 10) {
this.size = 10
} else {
this.size = size;
}
}

setModel(model) {
if (this.size === "big" && this.color === "blue") {
this.model = "mx-3200";
} else if (this.color === "red") {
this.model = "na-300";
} else {
this.model = model;
}
}
}

A reducer is a way to write the same with a different syntax:

function carReducer(state, action) {
switch (action.type) {
case "setColor":
return { ...state, color: action.color };

case "setSize":
const size = state.color !== "red" && size > 10 ? 10 : action.size
return { ...state, size };

case "setModel": {
const model =
state.color === "red"
? "ds-200"
: state.color === "blue"
? "mx-100"
: action.model;
return { ...state, model };
}
}
}

A reducer receives the current state, and an object representing the action to be executed (more or less, the same as calling an object's method). But instead of "storing" the state an object instance, it returns it as an object ("object" in OOP and FP have different meanings. In OOP means "instance of a class" and in FP means "a map of key and values" or "grouped data").

With typescript, we are enforced (and that's a good thing!) to write the types of both the state and the actions:

type CarAction =
| { type: "setSize"; size: number }
| { type: "setColor"; color: string }
| { type: "setModel"; model: string };

type CarState = {
size: number;
color: string;
model: string;
};

Previously on "React tapas"...

We wrote a component with several related state:

function useQuery(fetch: () => Promise<object>, queryParams: string) {
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<object>({});
const [error, setError] = useState<any>();

useEffect(() => {
setIsLoading(true);
fetch(queryParams)
.then((response) => {
setData(response);
setIsLoading(false);
})
.catch((err) => {
setIsLoading(false);
setError(err);
});
}, [queryParams]);

return { isLoading, data, error };
}

Write the reducer #

This is a reducer expressing the same "state logic":

type QueryState = {
isLoading: boolean;
data?: any;
error?: any;
};

type QueryAction =
| { type: "start" }
| { type: "success"; data: any }
| { type: "fail"; error: any }

function reducer(state: QueryState, action: QueryAction): QueryState {
switch (action.type) {
case "start":
return { ...state, isLoading: true }
case "success":
return { ...state, isLoading: false, data: action.data };
case "fail":
return { ...state, isLoading: false, error: action.error };
}
}

One first benefit of this approach is that forces us to think carefully what could happen (the actions) and what those actions produces in our system... without thinking about the UI changes, react or any other part of the system.

So it groups all previous (almost unnoticed) bits of business logic in the same place.

As a big bonus, the reducer is trivial to test.

Using reducers in React: useReducer #

React provides a hook to use the reducer, the "useReducer" hook.

  const [state, dispatch] = useReducer(reducer, {
isLoading: false,
});

The hook needs two parameters: the reducer and the initial state (there's always a state). Notice that we mark "data" and "error" as optional (the "?" at the type definition) so we don't need to provide them here.

The hook returns two things: the current state and a function (normally called "dispatch") to send (dispatch 🤷‍♀️) actions to the reducer. As you will see, we never call the reducer directly (something that, as I tried to explain in the video, is a recurring pattern in a declarative environment)

What useReducer does under the hood is:

With this hook, the code from the previous chapter can be written as:

function useQuery(fetch: () => Promise<any>, queryParams: string) {
const [state, dispatch] = useReducer(reducer, {
isLoading: false,
});

useEffect(() => {
dispatch({ type: "start" });
fetchData(queryParams)
.then((data) => dispatch({ type: "success", data }))
.catch((error) => dispatch({ type: "fail", error }))
}, [queryParams]);

return state;
}

Now not only we grouped all logic inside the reducer, but also we got a code that is much easier to reason about.

Bonus: don't use any (aka "TS generics crash course") #

The above state uses "any" which is ... bad :-(

Typescript has this wonderful and complex thing called generics. Basically a generic is a type that is not yet defined (normally expressed as a single capital letter).

The state with generics is:

type QueryState<T> = {
isLoading: boolean;
data?: T;
error?: any;
};

Mean: data is of type T where this type is not yet known

Since the action needs to use that unknown data type, you need to add generics:

type QueryAction<T> =
| { type: "start" }
| { type: "success"; data: T }
| { type: "fail"; error: any }

And also the reducer uses the state and actions, so needs the T:

function reducer<T>(state: QueryState<T>, action: QueryAction<T>): QueryState {
...
}

That can be read as: given a type T for the reducer, use the same type in the state and action

Finally, you can specify the actual T value like this:

[state, dispatch] = useReducer(reducer<PostResponse>, { isLoading: false });

Next chapter.... #

I want to explain the useRef hook and after that, see some real world codebases