NgRx vs Observable Services: Stately Matters
Comparing two popular ways of handling data flow in your application
With regards to maintaining state in your application, there is beauty in having a single source of truth, but that can come at the cost of troublesome overhead.
Just like all things in life, in development you cannot have your cake and eat it too. Of course it would be perfect to have a solution that helps common problems devs face such as event soup and change detection without any extra work. But alas, ‘tis not the way of the world.
Two popular solutions for maintaining state and optimizing data flow that I’ll be talking about today are NgRx, with its redux styled approach, and observable services, which contain a desired state for a “slice” of your app in which subscriptions can be created.
For this article, I have implemented a mini chat application to demonstrate the differences in code between NgRx and observable services. I will also be discussing my experience implementing both of them.
What is NgRx?
There is no way I can improve upon the definition from the NgRx documentation, so let me quote them directly -
“NgRx is a framework for building reactive applications in Angular. NgRx provides libraries for:
- Managing global and local state.
- Isolation of side effects to promote a cleaner component architecture.
- Entity collection management.
- Integration with the Angular Router.
- Developer tooling that enhances developer experience when building many different types of applications.”
What are observable services?
An observable service in Angular is a singleton that can be injected into your application. It provides accessors to manipulate data (such as adding an item to an array) and storing data.
Our example mini chat app
Time to see them both in action! Today we’ll be working with a basic “chat” app that consumes no service/backend API. With this app you can:
- View a list of preset channels
- Update a channel name
- Read messages from the in-memory cache
- Send a message
- Switch Channels
For each “feature” I’ll break down the differences between each implementation.
Structure of observable services vs structure of NgRx
All that is required is running
ng g s <service_name> twice, once for the channel service and then another time for the message service. Just two services are required - one to handle channel related logic and another for messages.
Quite a bit more work has to be done here - and keep in mind, this is excluding specs for the reducers and effects. Each file generated contains code that has its own, specific responsibility. There are many files, but at least I’m following #singleresponsibilityprinciple.
Feature breakdown - observable service vs NgRx
Here I’ll discuss the difference in approaches for each feature.
Feature 1: View a list of preset channels
I have a list of channels which is stored in a local member variable in the
ChannelService. I get the channels and channel updates by listening on the
Finally, in the component, I subscribe to the channels,
Firstly, I define what the state would look like, then provide default values:
Then I have to create a way to get the channels, which is accomplished via a selector. This can be thought of as a “query”:
Finally, in the component I tell the store to get this slice of data by using the selector I created.
this.selectedChannelId$ = store.pipe(select(selectCurrentChannelId));
Not too bad.
Feature 2: Update channel name
- Create the
updateChannelmethod in the service.
- Call the method from the component.
Note how the spread operator is used. This triggers change detection by returning a new array.
- Create an
- Add logic in the reducer to update the corresponding channel.
- Dispatch the
UpdateChannelaction in the component.
I know what channel to update this time around since I hardcoded some IDs for the channels. I didn’t have to update the selected channel as the reference to the channel didn’t change.
Feature 3: Read messages
- Create a
- To get messages, I use channel ids as keys and the array of messages as values in a
messagesstring array is used to track messages for the current channel, and handles pushing and return values in the
- Subscribe to the
messages$observable from the message list component.
Here I establish the
messageDB as well as the
messages getter and setter. I also get messages for the current channel from the
messageDB which happens when the user changes channels:
Then in the component:
This is a bit more complicated. However, it makes sense once all of the pieces are put together. The heart of the logic is the EntityAdapter.
- Create the entity adapter, and set what the primary key will be. In this case, I want the channel ID to be the primary key. The value will be an object that contains
entities, which is where our messages array will be. I’m using a
MessageContainerobject that has
channelIdand an array of
channelIdserves as a unique identifier for the messages. Example here: https://gist.github.com/michael-mckenna/b7389299c534a97625a6de783b84fa20
- Create the selectors. I had to utilize selector composition, which is a fancy way of saying I created a selector from selectors. I have a selector getting the selected channel ID, one for getting the list of
MessageContainers(messages for each channel), then I use those two to get the messages for the selected channel. Example here: https://gist.github.com/michael-mckenna/056baea580d6d7bc0f5013f8dee0bf4d
- To get the messages, I just had to use the composed selector created in (2). Example here: https://gist.github.com/michael-mckenna/db93226b0796572d627495c316c088a6
Feature 4: Add message
- Add a function to the
MessageServiceto add the message.
- Call this service function from the component.
- Create an
- Add a case in the message reducer to add the message to the message array in the entity corresponding to the selected channel ID.
- Dispatch the action from the component.Example: https://gist.github.com/michael-mckenna/d04e8706c2d04ceba7cfe38c0d6f12c9
Feature 5: Switch channels
- In the
MessageService, add a
selectedChannelIdproperty. This gets set when
getInitialMessagsForChannel(channelId: number)is called.
- When the user selects a channel, I keep track of that channel’s ID in both the
MessageService. When the value gets updated in the
ChannelService, it updates the value in the
MessageServicein the function listed above. The downside to this is that it creates tight coupling.
See lines 24-26 here: https://gist.github.com/michael-mckenna/cae21c80099a9da7212036fe42b0d061#file-message-service-ts-L24-L27
- Create a
SelectChannelIdchannel action and message action (remember, the selected channel id is being tracked in both states).
- Add a case in the reducer to set the
selectedChannelIdin the channel reducer and the message reducer.
- Create the
- Something new - creating a
selectChannel$effect. I want to set the selected channel id in the channel state, but I also need to in the message state. This is called side-effect (hence why NgRx gave it this name). The effect created dispatches a new action which is picked up by the message reducer so it can also update its selected channel id. The alternative approach requires coupling the Message state with the Channel state. However, this approach would be problematic because the message state relies on a value from another state to compose its selectors, which is an anti-pattern.
Final thoughts on NgRx vs observable services
These are based on my personal experiences using both and may not be applicable to all teams or use cases.
Observable service pros and cons
Pros of observable service:
The observable service method had a much quicker implementation. It’s slimmer, and has a very minimal learning curve. It’s rewarding getting something done relatively quickly.
Cons of observable service:
I ran into issues with how to handle storing messages across channels. I wanted the messages to be cached so when returning to a channel they were “already” there. I implemented a solution with a fake database using the Record type, ended up disliking it, then came up with the solution that ended up with the
messageDB I created. Still not sure if there is a better approach.
I also ran into an issue where I had to couple the
ChannelService and the
MessageService when tracking the selected channel’s id. If only there was some central store they could read from. 🤔
I can see the services blowing up fast for large applications. This would necessitate careful planning regarding how and when the services should be created. You wouldn’t want to overload a single service
NgRx pros and cons
Pros of NgRx:
No tight coupling.
Each logical bit in the app flow was stored in its respective component (e.g. the reducers, effects, etc). This helped make sures there was no questions about what should go where in the code.This in turn helped breakdown the app flow so you only have to focus on pieces of the puzzle at any point in time, instead of trying to keep large pieces of the flow in short term memory, like in the case of a large observable service.
Unidirectional data flow and clean handling of side effects. This ties in with the first point, but having a mechanism to handle side effects and help minimize event soup helps keep the code clean.
Cons of NgRx:
(Story Time - tl;dr it took way longer) I have been working with a mature project that uses NgRx, and this still took longer than the observable service implementation. I never worked on a project setting up the store (all the actions, state, selectors, effects, and reducers) from scratch, so there was a learning curve setting everything up. At one point I had to learn a new concept called an Entity Adapter (used to get messages given a specified channel ID), and that had a learning curve to it as well.
To put things into perspective, I planned out the app structure, created the components, created the UI designs, and added in the observable service logic first, and that whole process was still faster than adding in ONLY the NgRx implementation (the NgRx implementation re-used the UI and largely re-used the components).
However, that does not necessarily mean NgRx is worse, it just because it took longer! This was likely more of an indication of the two learning curves I had to go through.
More cons of NgRx:
- In a large application it can become difficult to follow the series of actions/effects/reductions that take place. Upon getting acquainted with the code base, this becomes clear, but still leads to a longer learning curve for new devs, or for devs revisiting code that was written a while ago.
- All the files. As mentioned before, there is really no way around this. It’s just imperative that the folder structure is set up in the best way possible to allow easy navigation. This becomes increasingly important with larger code bases, as the number of files will increase exponentially.
- Writing more code overall. You have to write the actions, effects, reducers, and selectors, then write tests for each of them. However, because these components are all decoupled and broken down into smaller, pure functions, it makes writing tests easier and makes the tests cleaner.
Should I use observable service or NgRx?
You’ve likely heard this time and time again, but use whatever works for your team. Asking yourself the right questions can help here.
If nobody on your team has experience working with NgRx/redux, but you really want to use it, can you afford the extra time to learn it? Does your organization require automated unit tests (e.g. Karma, Cypress), or do you depend on manual testing to get by? There are already many files and components to NgRx, and each one will need to have an associated unit test, so you will need to keep in mind the extra work that is required to maintain this large test suite.
DISCLOSURE STATEMENT: © 2022 Capital One. Opinions are those of the individual author. Unless noted otherwise in this post, Capital One is not affiliated with, nor endorsed by, any of the companies mentioned. All trademarks and other intellectual property used or displayed are property of their respective owners.