export interface Action { type: string }
This is what defines an Action.
Ngrx implements a message passing architecture, where Actions are dispatched.
this.store.dispatch(action)
The mechanism behind this is an ActionSubject, which is a BehaviorSubject with some extensions. When you dispatch an action, it is as simple as this.
this.actionsObserver.next(action);
The listeners subscribe to this action stream, either in the reducers which modify the state, or an Effect. This simple structure allows you to build a message passing system which define the data flows in your application.
Here is a list of some things you need to know:
- Actions are defined by { type: string }. The function name of your createAction function does not define the action.
- The string must be unique.
- Every reducer and every Effect sees every Action that is dispatched. This includes forFeature loaded reducers and Effects. Effects and reducers filter out the Action it is listening for.
- The suggested pattern for the string is "[where it came from] what it does".
- Reducers and effects can listen to multiple Actions to do the same thing. So if you dispatch an action that loads your Contacts list from the contacts module and the invoice module, two different Actions can be defined, with 'where it came from' in the string. The reducers and effects accept a list of actions to respond to.
- Actions can have other properties. The command can be accompanied by data.
A well designed message passing system will clearly define the paths of execution and transfer of data. I find the easiest way of seeing this in my applications is to log out the action from a reducer.
export function reducer(state: State | undefined, action: Action) {
console.log(action.type);
return emailReducer(state, action);
}
You will see the flow of commands and data as your application goes through it's function.
Broadly, there are two types of Actions. Those that the reducers listen for to modify the state, and those that don't, and are listened for in Effects. Reducers are pure functions, and when something asynchronous or a side effect needs to occur, the Effect will listen. The typical example goes like this
- Component dispatches an action to load data.
- an Effect listens for the load action, and does an api call to fetch the data.
- the Effect emits an action with the data attached.
- the reducer listens for that action and updates the state.
Message passing systems can easily get out of hand. I think the key is to use a declarative approach. An example of a declarative system that easily translates into actions is a file upload component that I wrote. It has the input to select a file or files, a table to contain the list, and buttons to upload them individually or all of them.
- selected file or files are inserted in the state
- a file or all files can be removed from the state
- a file or list of files are uploaded
- the upload progress is displayed
- success clears the state
- an error sets the status
This is how it is translated into Actions
export const InsertFile = createAction(
'[UploadTableComponent] Insert file',
props<{ selectedfile: SelectedFileList }>()
);
export const RemoveFile = createAction(
'[UploadTableComponent] Remove file',
props<{ selectedfile: SelectedFileList }>()
);
export const SetFileStatus = createAction(
'[SelectedFileList Effect] Set File Status',
props<{ selectedfile: SelectedFileList, status: string }>()
);
export const SetFileProgressStatus = createAction(
'[SelectedFileList Effect] Set File progress Status',
props<{ selectedfile: SelectedFileList, progress: number }>()
);
export const SetFileProgressComplete = createAction(
'[SelectedFileList Effect] Set File progress Complete',
props<{ selectedfile: SelectedFileList }>()
);
export const SelectAllFiles = createAction(
'[UploadTableComponent] Select All',
props<{ id: string, module: string, selectall: boolean }>()
);
export const SelectFiles = createAction(
'[UploadTableComponent] Select File',
props<{ selectedfile: SelectedFileList }>()
);
export const UploadFiles = createAction(
'[UploadTableComponent] Upload File',
props<{ selectedfile: SelectedFileList }>()
);
export const UploadSelected = createAction(
'[UploadTableComponent] Upload Selected',
props<{ selectedfile: SelectedFileList, process: string }>()
);
In this component the file select input dispatches the InsertFile action. Buttons on the file list table can either remove or upload an individual file, or remove or upload the whole list. The httpclient emits actions to update the progress and completion of the upload, or notify of an error.
If you lay out a declarative description of your component, it makes the flow clear and understandable, and it is easy to translate into actions. All that is then required is to define the data that gets passed around. Then when the component gets executed, logging the actions will match the declarative description, or expose flaws in the implementation that can be fixed.
The goal of the NGRX pattern is to make complicated data flows and interactions easy to understand. Self documenting Actions, along with a well thought out declarative spec for the flows and interactions will lead to a successful implementation.
Application state is the data required to render the views over time.
One of the conceptual difficulties that makes Ngrx difficult is how to structure the state. There is no one answer because the source, usage and modification of the state is different in every app.
This is how I approach it.
The path between the api and components represents a series of Ngrx actions and functions. This is one direction of the data flow.
- The component dispatches an action
- An effect watches for that action and runs an http call that fetches the data
- On success a second action is dispatched containing the data
- The reducer responds to the action and updates the state
- A selector emits the data in a shape useful for the component
- The component renders the view.
The first three are simply the mechanics of sending a command and having it execute. The last two, the selector and component render are what determines what you do in 4, the reducer.
Something I've been working on recently illustrates the challenge. I have a component with a map, a table and a day selection component for viewing the locations and routes in a workday of a service tech. The data comes from gps logs, in different formats and different levels of detail. Some are simply a list of time and locations, others are the result of analysis and have lists of routes and places.
Much of the grunt work of assembling the data is done on the server, and the client gets an array of routes and stop points. The goal of the component is to come up with timesheet data, travel distances for expenses, and billing information for the services rendered; when and where and how long.
The three components render different aspects of the data.
- The day selection component renders the selectedday, which comes from the router url.
- The map renders routes and stop points, using the map functions and classes.
- The table lists the same routes and stop points, with duration, distance, at a specific location identified as an address and/or business location.
The selectedday is driven by the UI; the user selects the day or passes the day via the url. A change of day dispatches an action which fetches the location data for that day.
The map selector gets the list of routes and places, builds the map classes. The component subscribes and attaches the UI callbacks.
The table selector puts together a list extracted from each location item in the state, with duration, distance and any other data that is available.
Imaging the data flowing and being modified from the server => effect => reducer => state => 3 selectors. If the reducer processed the data into the map classes, the table wouldn't get what it needs. Same if the reducer processed the data into the shape that the table required. There is a point in that line of modifications before it branches for the three selectors, and that is what you want your reducer to put in the app state.
If you are only viewing the data, this point is usually quite simple to figure out. But what if you are editing the data?