Actions in the Redux paradigm are the initiators of the one-way dataflow process for state management. Once an action is triggered, or rather dispatched, the process is kicked off for new state to be composed - which is typically composed by the payload we sent through our dispatched action. What we want to learn is how to properly create, structure, and use actions to our full advantage with NGRX Store and TypeScript.
Typical redux patterns used to created actions come in the form of plain objects, or pure function wrappers that act as action creators. However by adding Typescript, we see even more benefit at hand when it comes to using classes to compose actions. So, let’s take a dive into actions and how we can maintain a clean structure that’s both human readable, easy to maintain, concise and reaps the benefits that Typescript provides us.
Table of contents
Traditional Redux Patterns
Traditionally, in Redux, actions are sent as objects that instruct the store what to do with its current state, and the incoming new state we need to compose somewhere in our reducer. Something like this should look familiar:
// todo.component.ts
this.store.dispatch({
type: 'ADD_TODO',
data: { label: 'Eat pizza', complete: false },
});
This is how Redux is typically taught, and with good reason! We need to grasp the pure API before we can get clever. So let’s look at some next steps we could adopt.
The problem we can face with the above approach is that the Action type is expressed as a string, for one it’s prone to error by typos, and secondly we lose type checking to create a contract between our action#type
and its string value. And our beloved auto-completion. We can easily enhance our developer experience by adopting an approach using Action Constants.
Action Constants
Instead of relying on a string to refer to our intended action type, we can abstract it into an Action Constant, providing our string type as the value:
// todo.actions.ts
export const ADD_TODO = 'Add Todo';
Notice how the previous action type value becomes the name of a constant that points to a more readable string, and you can make it as human readable as you like!
We can easily refer to this action name anywhere from the application and have the guarantee that we will always get it right. We only have to type the string once and, since it is a literal constant, it won’t be able to be modified anywhere else in the application.
This can be further improved though! Action Constants act as unique identifiers for an action. Since there can be many actions in an application corresponding to different slices of the store, one way that we can guard our store from duplicate action logical failure is by using the concept of an Action Namespace. Check this out:
// todo.actions.ts
export const ADD_TODO = '[Todo] Add Todo';
We simply append a namespace to the Action Constant that, ideally, corresponds to the name of the slice of the store that we are using - typically the name of the feature module you’re currently working on.
If we ever find ourselves debugging the application through logging actions, this namespace will make it clear what store slice and what action context we are troubleshooting, as we’ll see something like this (imagine we switch views from “Todos” to “Dashboard”):
[Todo] Add Todo
[Todo] Add Todo Success
[Dashboard] Add Todo
[Dashboard] Add Todo Success
In the above example, we might have the ability in the “Dashboard” module to add todos to a particular user, rather than just create them elsewhere in the “Todo” module. Think about real world use cases and how to make debugging across modules easier.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
It further improves the readability of our code, as well as our stack traces of actions in the Redux devtools. Additionally, there can now be more than one 'Add Todo'
without creating any conflict. The namespace prevents the 'Add Todo'
actions to collide as it ties them to a specific state context in our module.
Now, we can refactor our action to look like this:
// todo.actions.ts
export const ADD_TODO = '[Todo] Add Todo';
// todo.component.ts
import * as fromActions from './todo.actions';
this.store.dispatch({
type: fromActions.ADD_TODO,
payload: { label: 'Eat pizza', complete: false },
});
Since the Action value has changed, we must reflect that in our reducers too by using the same variable. Here’s what it’ll look like once we’ve switched that out to using our new constant:
// todo.reducers.ts
import * as fromActions from './todo.actions';
export interface TodoState {
loaded: boolean;
loading: boolean;
data: Todo[];
}
export const initialState: TodoState = {
loaded: false,
loading: false,
data: [],
};
export function reducer(state: TodoState = initialState, action) {
switch (action.type) {
// before: case 'ADD_TODO': {
case fromActions.ADD_TODO: {
const data = [...state.data, action.payload];
return { ...state, data };
}
}
return state;
}
We can now forget about the details of the definition of the action and focus on what it does for us. However, we can take this one step further by using those Action Creators we keep talking about…
Action Creators
So far we’ve got to this point:
this.store.dispatch({
type: fromActions.ADD_TODO,
payload: { label: 'Eat pizza', complete: false },
});
But we find ourselves building that same action definition object over and over. This is a repetitive task that quickly becomes tedious, as well as the fact of manually typing a whole object with two properties each time. So what if we could use something that builds that actions object for us?
Pure Function Wrappers
First, let’s try a pure function wrapper:
// todo.actions.ts
export const ADD_TODO = '[Todo] Add Todo';
export const AddTodo = (payload) => {
return { type: ADD_TODO, payload };
};
// or, being clever:
export const AddTodo = (payload) => ({ type: ADD_TODO, payload });
We create a pure function called AddTodo
that returns the action definition object with the correct type and the desired payload.
In the component we’d end up with:
// todo.component.ts
this.store.dispatch(
fromActions.AddTodo({ label: 'Eat pizza', complete: false })
);
This convenient pattern looks better and it improves our productivity and efficiency! We no longer care about specifying the type
property, we just reference the correct action creator.
TypeScript Classes
We can also go even further though with TypeScript classes, my preferred approach:
// todo.actions.ts
export const ADD_TODO = '[Todo] Add Todo';
export class AddTodo {
readonly type = ADD_TODO;
constructor(public payload: any) {}
}
Why a class? With a TypeScript class, we are able to add extra safety to our Action Creator.
By using readonly
, we establish that type
can only be assigned a value either during the initialisation of the class or from within the class constructor. The value of type
cannot be modified at any other time. This treats type
as a “class constant”.
You may be wondering, why not then just type the action type string here instead and avoid creating an Action Constant? The reason is that we will use the Action Constants again in our reducers as we’ve demonstrated already.
We can use the class constructor
to not just receive the payload
but also to enforce a desired type for that payload
. In the example above, we are allowing anything to be sent as a payload, but we could always enforce strong typing in the argument if we don’t expect to receive anything else:
// todo.actions.ts
import { Todo } from '../models/todo.model';
export const ADD_TODO = '[Todo] Add Todo';
export class AddTodo {
readonly type = ADD_TODO;
constructor(public payload: Todo) {}
}
Now we can dispatch our action in this way (notice new
keyword to create a new instance):
// todo.component.ts
this.store.dispatch(
new fromActions.AddTodo({ label: 'Eat pizza', complete: false })
);
If we were to dispatch it with the incorrect type:
// todo.component.ts
this.store.dispatch(new fromActions.AddTodo(42));
TypeScript will warn as that we are sending the wrong argument type and issue a compiler error.
NGRX’s Action Interface
In an NGRX context, we are offered an Action interface that allows us to ensure that our Action Creator classes always have the proper configuration:
export interface Action {
type: string;
}
You’re right type
isn’t much to look at, but we’ll find out the reason for this hidden gem as we continue.
Note that NGRX doesn’t force us to use a payload
property for our actions anymore. This is another reason why we could implement Action Creators, and we’ll cover it in a second.
Continuing with our interface in our Action Creator class, we get:
// todo.actions.ts
import { Action } from '@ngrx/store';
export const ADD_TODO = '[Todo] Add Todo';
export class AddTodo implements Action {
readonly type = ADD_TODO;
constructor(public payload: Todo) {}
}
Exporting Types for Reducers
By using a class, we can also export it as a type that we can use in other files, such as our reducers, for type checking:
// todo.actions.ts
import { Action } from '@ngrx/store';
export const ADD_TODO = '[Todo] Add Todo';
export const REMOVE_TODO = '[Todo] Remove Todo';
export class AddTodo implements Action {
readonly type = ADD_TODO;
constructor(public payload: Todo) {}
}
export class RemoveTodo implements Action {
readonly type = REMOVE_TODO;
constructor(public payload: Todo) {}
}
// exporting a custom type
export type TodoActions = AddTodo | RemoveTodo;
We’ve mentioned reducers, so let’s see how this all ties up with them. Currently we have this, and our action
argument remains untyped:
// todo.reducers.ts
import * as fromActions from './todo.actions';
export interface TodoState {
loaded: boolean;
loading: boolean;
data: Todo[];
}
export const initialState: TodoState = {
loaded: false,
loading: false,
data: [],
};
export function reducer(state: TodoState = initialState, action) {
switch (action.type) {
case fromActions.ADD_TODO: {
const data = [...state.data, action.payload];
return { ...state, data };
}
case fromActions.REMOVE_TODO: {
const data = state.data.filter(
(todo) => todo.label !== action.payload.label
);
return { ...state, data };
}
}
return state;
}
When we assign our custom type to the action
, the switch
cases are then safety netted against incorrect typing of the action.type
, and also our action.payload
(or action.anything
) value has the type inferred. This mitigates another point of failure, and gives us that flexibility to adopt custom payload
property names.
Also, as our Action Creators are exported as types, we can also use them to ensure that the reducer is always getting the correct action. Pass an unexpected Action and you get a warning from TypeScript again.
Here how we can simply type the action
:
// todo.reducers.ts
export function reducer(
state: TodoState = initialState,
action: fromActions.TodoActions
) {
switch (
action.type
// ...
) {
}
return state;
}
We actually could’ve used the Action
type provided by NGRX instead:
export function reducer (
state: TodoState = initialState,
action: Action
)
However, this presents a critical problem when using TypeScript. Since the payload
property of Action
is not defined, when trying to access the action’s payload within our reducer, we would get an error. For example:
const todo = action.payload;
TypeScript will warn us that Property 'payload' does not exist on type 'Action'
.
If only we told TypeScript that payload
is part of our Action object… That’s exactly what did with our action creator, remember we implement the Action
:
export class AddTodo implements Action {
readonly type = ADD_TODO;
constructor(public payload: Todo) {}
}
Our custom type not only gets rid of the error but will also allow our IDE/text editor to also offer us code completion.
Conclusion
We’ve looked at some reasons why and how we can adopt new code changes to further streamline working with the Redux pattern in NGRX. It can sometimes feel like you’re creating additional boilerplate - but the benefits are tenfold when the approach is scalable.
By using a combination of Action Constant, Action Creator, TypeScript and the Action interface, we have allowed ourselves to mitigate different points of failure: typing the wrong action, sending the wrong arguments, misconfiguring an action, and even creating the wrong action. On top of that, our reducers have also become more streamlined and easier to test. What started as a simple JavaScript object has transformed into a pretty bulletproof addition to your state management strategy.