Picking up new technologies can seem like a hefty endeavour, but is often actually worth the time; learning "new tricks" like the Flux Pattern (implemented by Redux implementations such as ngRx for Angular) can be both simple and powerful. This particular state management trick helps to move all the bits and pieces of state to a single, abstracted store that makes managing state as easy as pie. Here's how you can get started with Redux and why it's so valuable.
For a long time, building what seemed to be the umpteenth front-end with Angular, I kept running into the same two problems:
- Keeping different components' view of the same data in sync, and
- Letting all subscribers to the same data know that something has changed in case they needed to keep in sync
This sparked a great exploration into finding greener pastures - enter ngRx, a Redux implementation.
This "new trick" simplified, updated and controlled my front-end application state in a predictable and consistent manner. By abstracting state to a single, centralized store (where access to it is controlled by ngRx) any component can subscribe to the data and dispatch commands to update. This significantly cleans up your code at a component level, and makes the transition from a small-to-medium-sized application to the realms of enterprise front-ends far more sustainable. In my experience, apps that don't take state management seriously simply crash and burn.
In our company, I set out to simplify the flow and manipulation of data to minimise inter-component communication, inevitably resulting in a cleaner, leaner (meaner?) and easier-to-follow code base.
Some initial considerations
Before diving face-first into a Redux implementation, it important to understand the difference between Local UI state and Global application state. In other words, state that is local to a component vs state that is shared between components. This is because different types of functions in a front end are better implemented in specific states .If the state is used by other components, it's global, otherwise it will be local UI state. Even though it is entirely possible to manage everything within a Redux store, it is not advisable and not necessary.
Also, it is worth bearing in mind that this kind of state management is overkill if you're only working with smaller applications; it proves far more useful, for example, for an enterprise level front-end application framework.
With that in mind, then, I will show you how to get started with Redux and Angular 7 in a simple to follow Todo application. Furthermore, I'll show you a nice clean structure to follow in your own application designs going forward.
The setup
Let's proceed to installation of the necessary packages via npm (or yarn) if you so prefer. This is a command that you can run:
npm i --save @ngrx/store @ngrx/router-store @ngrx/effects @ngrx/store-devtools
Please ensure that all 4 packages listed above appear in your package.json file in your project.
Step 1: Setup actions
Cool, now we can move on to the good stuff. We will start by adding actions that you can think of like commands for Redux. These are dispatched via the redux store and are handled by reducers, which we will set up shortly. We create a store folder that will house all the redux specific functionality. You will create a folder such as this on a per module basis which will allow you to easily manage state per module and reduce the size of your reducers going forward. Your structure will look something like:
So, lets make some actions! The actions file layout should be along the lines of the following:
todo.actions.ts
import { Action } from '@ngrx/store';
import { TodoModel } from '../../shared/models/todo.model';
export enum TodoActionType {
AddTodo = '[Todo] Add Todo',
RemoveTodo = '[Todo] Remove Todo',
UpdateTodo = '[Todo] Update Todo',
SelectTodo = '[Todo] Select Todo',
SetTodos = '[Todo] Set Todos'
}
export class AddTodo implements Action {
readonly type = TodoActionType.AddTodo;
constructor(public payload: TodoModel) {}
}
export class RemoveTodo implements Action {
readonly type = TodoActionType.RemoveTodo;
constructor(public payload: string) {}
}
export class UpdateTodo implements Action {
readonly type = TodoActionType.UpdateTodo;
constructor(public payload: TodoModel) {}
}
export class SelectTodo implements Action {
readonly type = TodoActionType.SelectTodo;
constructor(public payload: TodoModel) {}
}
export class SetTodos implements Action {
readonly type = TodoActionType.SetTodos;
constructor(public payload: TodoModel[]) {}
}
export type TodoActions = AddTodo | RemoveTodo | UpdateTodo | SelectTodo | SetTodos;
Important to note here is the custom action type that is implemented per action. This is imported from ngrx/store. Additionally, the enum is a good way to avoid typos, as you can use string literals. If a typo is made, it becomes a painful exercise to locate a bug.
Step 2: Set up reducers
Next, we will set up the reducer function that will listen for the actions defined above and do something once the command is dispatched. A reducer is just a function that accepts state and actions. Very important to remember is to initialize the state of the reducer with a default value, as failure to do so will result in unexpected and difficult to debug behaviour. Your reducer will be something like the following:
todo.reducer.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { cloneDeep } from 'lodash';
import { TodoModel } from '../../shared/models/todo.model';
import { TodoActions, TodoActionType } from './todo.actions';
export interface TodoState {
todos: TodoModel[];
}
const initialState: TodoState = {
todos: []
};
export function todoReducer(state = initialState, action: TodoActions): TodoState {
switch (action.type) {
case (TodoActionType.AddTodo): {
const newTodo = cloneDeep(action.payload);
const updatedTodos = cloneDeep(state.todos);
updatedTodos.push(newTodo);
return {
...state as TodoState,
todos: updatedTodos
};
}
case (TodoActionType.RemoveTodo): {
const currentTodos = cloneDeep(state.todos);
const itemToRemoveIndex = currentTodos.findIndex((todoItem) => todoItem.id === action.payload);
if (itemToRemoveIndex > -1) {
currentTodos.splice(itemToRemoveIndex, 1);
}
return {
...state as TodoState,
todos: currentTodos
};
}
case (TodoActionType.UpdateTodo): {
const currentTodos = cloneDeep(state.todos);
const todoToUpdateIndex = currentTodos.findIndex((todo: TodoModel) => todo.id === action.payload.id);
currentTodos[todoToUpdateIndex] = cloneDeep(action.payload);
return {
...state as TodoState,
todos: currentTodos
};
}
case (TodoActionType.SelectTodo): {
const updatedTodos = cloneDeep(state.todos);
updatedTodos.map((todoItem) => {
todoItem.selected = action.payload.id === todoItem.id;
return todoItem;
});
return {
...state as TodoState,
todos: updatedTodos
};
}
case (TodoActionType.SetTodos): {
return {
...state as TodoState,
todos: action.payload
};
}
default: {
return state;
}
}
}
export const getTodoFeature = createFeatureSelector('todo');
export const getTodos = createSelector(
getTodoFeature,
(state: TodoState) => state.todos
);
In my reducer, I have a switch that handles the different types of actions that we set up earlier. Reducers are the only place in a redux implementation that change the values in the store. The state is immutable and you should never directly manipulate the state. If you do, you will not only violate the pattern, but you will have very difficult-to-find bugs and Redux will not function correctly (see here for immutability patterns in Redux).
With that in mind, create a new state with the cloned values of the previous state, which will be modified and ultimately returned. A default case is good practice here, because you can ensure whether your store is fault-tolerant or not by dispatching an unknown action and seeing how it handles that.
Step 3: Set up the store
The last thing to do now is to hook all this code up to the modules. In feature modules, the following will be needed in the imports for the module:
todo.module.ts
@NgModule({
imports: [
...
StoreModule.forFeature('todo', todoReducer)
],
exports: [],
declarations: [
...
],
providers: []
})
export class TodoModule {
}
In the above, "todoReducer" is your reducer set up above, and the string literal is the name of the module which you will be able to view in the store with redux devtools. Now, over to the main module file of the application, the final bit of setup to bring this all together needs to take place.
app.module.ts
// some more imports
import { StoreRouterConnectingModule } from '@ngrx/router-store';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from '../environments/environment';
// even more imports
import { appReducers } from './store/app.reducer';
@NgModule({
declarations: [
AppComponent,
HomeComponent
],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
AppRoutingModule,
MatToolbarModule,
MatButtonModule,
StoreModule.forRoot(appReducers),
StoreRouterConnectingModule,
!environment.production ? StoreDevtoolsModule.instrument() : []
],
providers: [TodoService],
bootstrap: [AppComponent]
})
export class AppModule { }
An important point to note here is that I did not have to do anything in my main app reducer file for this setup. This is due to the TodoModule being lazily loaded. The "forFeature" method of the store allows that bit of state to be added to the main store at runtime if and when the module is loaded. If your module is not lazily loaded, you will have to make an entry into the main app reducer. You can check that out in the example project for this post (for the "auth" feature module) by following the Github link under "Useful Resources" at the end of this post.
Otherwise, to access your data in the component in which it is needed is very simple. All you need is an instance of the store and an observable for your data. I used a selector with ngRx ("fromTodo.getTodos" in the code below) which you can learn about here. This will further simplify your code base.
todo-list.component.ts
//Some irrelevant imports
import { select, Store } from '@ngrx/store';
import { uniqueId } from 'lodash';
import { take } from 'rxjs/operators';
import { Observable } from 'rxjs/internal/Observable';
import { TodoModel } from '../../shared/models/todo.model';
import { TodoService } from '../services/todo.service';
import * as fromTodo from '../store/todo.reducer';
import * as TodoActions from '../store/todo.actions';
// component definition here
export class TodoListComponent implements OnInit {
@ViewChild('todoText') todoTextBox: ElementRef;
public todos$: Observable<TodoModel[]>;
constructor(
private store: Store<fromTodo.TodoState>,
private router: Router,
private todoService: TodoService) {
this.todos$ = store.pipe(
select(fromTodo.getTodos)
) as Observable<TodoModel[]>;
}
public ngOnInit(): void {
this.todoService.getTodos()
.pipe(
take(1)
)
.subscribe((data: TodoModel[]) => this.store.dispatch(new TodoActions.SetTodos(data)));
}
public addTodoHandler(todoText: string): void {
const newTodo: TodoModel = new TodoModel(
null,
todoText,
false,
false);
this.todoService.createTodo(newTodo)
.subscribe((todoItem: TodoModel) => {
console.log(todoItem);
newTodo.id = todoItem.id;
this.store.dispatch(new TodoActions.AddTodo(newTodo));
});
this.todoTextBox.nativeElement.value = '';
}
public itemSelectedHandler(item: TodoModel): void {
this.store.dispatch(new TodoActions.SelectTodo(item));
}
public viewTodoHandler(): void {
this.todos$
.pipe(take(1))
.subscribe((items: TodoModel[]) => {
const selectedTodoIndex = items.findIndex((todoItem) => todoItem.selected);
this.router.navigate(['/todos/' + items[selectedTodoIndex].id]);
});
}
public completeTodoHandler(): void {
this.todos$
.pipe(take(1))
.subscribe((items: TodoModel[]) => {
const itemToUpdateIndex = items.findIndex((todoItem) => todoItem.selected);
const itemToUpdate = items[itemToUpdateIndex];
itemToUpdate.completed = true;
this.todoService.updateTodo(itemToUpdate.id, itemToUpdate)
.subscribe((data) => {
this.store.dispatch(new TodoActions.UpdateTodo(itemToUpdate));
});
});
}
}
With the interactions with the store, I also took a pessimistic approach. The store is only updated once the service call to do the update returns successfully. The coolest bit about this approach is that - should the todos in the store be updated somewhere else in the application, such as an effect workflow that you can setup with push notifications from a server with socket.io or signalr - the view is automatically updated without the need for a fetch. This is because it's an observable to the store! Now, all the views that use this data will have a consistent view of the information that is free of sync issues.
Why I went about it this way
Firstly, I would like to reiterate that a Redux implementation is super overkill for tiny applications, such as a four page content site with a "Contact us" form. An implementation such as this is relevant when you have multiple views on the same data, or there is data that is either slung around the application or fetched redundantly from multiple places.
The demonstrated structure is one that is a standard within the ngRx space with Angular and is a very clean and scalable approach to state abstraction within your application. Implementing Redux will greatly simplify the flow of data in your front-end system and reduce the need for pesky services all over the place.
What I learned
Apart from realising that I have become less competent with "people language" from being a dev, I have learned to approach problems differently and think about data flow through my applications from an alternate perspective. I have found that copious amounts of unnecessary code at component level can be abstracted and delegated to other mechanisms using the ngRx framework, such as using Effects to update the store (not discussed here, but further reading has been added below). This is something that really cleans up and simplifies your project code. It's incredibly valuable for anyone looking to improve their scaling and write cleaner front end systems that are easy to maintain and extend. For enterprise level front-end application frameworks, this kind of state management is a hugely helpful tool to utilise.
If you'd like to read further on how you can integrate these kinds of systems to an even greater degree, I have added some links below. You can also follow my Github blog and read more about the kinds of dev work that I do. My hope is that, like I did, you can use this kind of simplification and apply the same thinking across the board.
Useful Resources:
- https://rxjs-dev.firebaseapp.com/api
- https://ngrx.io/docs
- Cool Flux pattern explanation: https://code-cartoons.com/a-cartoon-guide-to-flux-6157355ab207
- Example project on Github: https://github.com/AlbertJvR/Angular7_Todo
Albert Janse van Rensburg is a full-stack software developer at IoT.nxt, wielding experience in a broad variety of technologies and industries - which is totally not part of an elaborate plan to build his own death star (cough cough). He has built and optimised a vast number of enterprise solutions and, when not plugged in to the world of IoT, he tinkers with a variety of different technologies and ideas. Find him on Github to see more of what he does!