State management in Angular with observable store services
by Jure Bajt · 16 Jan 2018This post was originally published on jurebajt.com.
Effective state management in front-end development is a challenge, especially in larger and more complex single page applications. Right now, Redux is probably the most popular way of managing state. It is based on a few main ideas:
- One source of truth (app state).
- State is modified in a “pure” way via reducers.
- Reducers are invoked by emitting events to them.
- Interested entities are notified about state updates.
At Zemanta we have a client facing dashboard application built as a hybrid Angular app (running AngularJS and Angular side by side). AngularJS part of the app stores some state in components’ controllers and other in global services (implementing a pub-sub pattern). Every feature manages its state in a slightly different way because there are no clear conventions set about state management. As a consequence, the more features we add, the harder it becomes to ensure the state stays consistent across all components and services.
The process of upgrading to Angular gave us the opportunity to rethink how we tackle state management in the app. We didn’t want to introduce another layer of complexity by adding a state management library to the codebase. New Angular framework, TypeScript, new build system and hybrid app bootstrap already brought a lot of additional complexity to the mix. Instead, we used the ideas from Redux to create a state management solution that leverages Angular’s (and RxJS’s) features to do its job.
One could argue that developing a custom solution for state management introduces additional complexity to the codebase too. It would be naive to dismiss such claims. The difference though is in how much of this complexity is added by developing features using Redux versus observable store pattern. The solution we developed is a really stripped down version of Redux. It does not “prescribe” how to handle async actions, how to combine reducers, how to implement middleware etc. Its only role is to provide a simple API to update state object and to subscribe to its updates. Stores are otherwise just good ol’ Angular service classes.
This post explains how one can use the observable store pattern we developed to manage state in Angular apps. The solution was inspired by the following article from Angular University: How to build Angular apps using Observable Data Services.
To showcase the usage of observable stores we’ll build a simple app called Coffee election that lets its users vote for their favorite type of coffee and add their own coffee type to the list of candidates. The source code is available on GitHub: github.com/jurebajt/coffee-election.
Abstract Store
class
At the core of observable store pattern is the abstract Store
class. It leverages RxJS to achieve data flow similar to Redux. It is implemented like this:
The store’s state (_state$
) is a RxJS BehaviorSubject
. Changing the state means pushing new state object into the _state$
stream via the setState
method. Interested entities can subscribe to state updates by subscribing to the state$
property. It is also possible to get the current state via the state
property without subscribing to state updates.
Store
class provides a unified interface for all features’ store services to extend. In the next section we’ll have a look at how to use the abstract Store
class to implement an example feature store service.
Features’ stores
Feature specific stores are Angular Injectable
s extending the abstract Store
class:
In the code snippet above note the CoffeeElectionState
type used when extending the Store
. Specifying CoffeeElectionState
as the store type adds correct type definitions to the generic store.
CoffeeElectionState
is a class representing state object with initial values. In the Coffee election example app it looks like this:
One last thing to do to make this simple example work is to add a super
call to CoffeeElectionStore
’s constructor in order to correctly initialize the state when creating a new instance of CoffeeElectionStore
:
With the above code in place, each instance of CoffeeElectionStore
has a way of setting its state and getting the current state or an observable of the state. To make it more useful, additional methods to modify the state (similar to Redux reducers) should be added:
In the example above CoffeeElectionStore
’s functionality was extended by defining addVote
and addCandidate
methods. In essence, these methods modify the state by pushing new state objects into the observable state$
stream via the setState
method.
Note how it is impossible to modify the state without notifying listeners about the change. This characteristic of observable stores makes them a perfect fit for implementing one-way data flow in Angular apps - much like with Redux or a similar state management library.
Using injectable store services
App’s state could all be stored in a single global state object. But as the app grows, so does the state object and it can quickly become too big to easily extend it with new features. So instead of storing the whole state in one place, it is better to split the state into smaller chunks. A good way to split the properties is to group them by feature and extract these groups into separate state objects, managed by corresponding stores.
There are two types of stores that emerge from splitting:
- global stores that contain globally used state,
- component stores that contain the states used by a single component.
To set up a store containing global state accessed by different services and components, the store is listed as a provider in a module’s providers list (root app module or a feature specific module). This way Angular adds a new global provider to its dependency injector. The state in global stores will be available until the page is reloaded.
app.module.ts:
Note that many global stores can be defined as providers in app’s modules, each managing its own subset of global state. The codebase stays much more maintainable this way, since each store follows the principle of single responsibility.
To use a global store in different parts of the app, the store needs to be defined as their dependency. This way Angular injects the same instance of a global store (defined as singleton provider in AppModule
or any other module) into every component/ service depending on it.
example.component.ts:
Not all state needs to be global though. Component specific state should only exist in memory if a component is using it. Once user navigates to a different view and the component is destroyed, its state should be cleaned-up too. This can be achieved by adding the store to a list of component’s providers. This way we get “self-cleaning” stores that are kept in memory as long as components using them are kept in memory.
example.component.ts:
Private component stores are used in the same way as global stores by defining them as dependencies in the components’ constructors. The key difference is that these component specific stores are not singletons. Instead, Angular creates a new instance of the store each time a component depending on it is created. As a consequence, multiple instances of the same component can be present in the DOM at the same time, each one of them having its own store instance with its own state.
Subscribing to state updates in components and services
Once a store instance is injected into a component or service, this component/ service can subscribe to state updates. In the example of coffee-election
component, subscribing to state updates looks like this:
It is also possible to only subscribe to updates of a subset of state:
Note that these subscriptions must be cleaned up before a component is destroyed in order to prevent memory leaks. We won’t go into details about unsubscribing in this post. Check out this topic on Stack Overflow to learn more.
Subscribing to state updates in components’ templates
In case a component doesn’t execute any logic on state update and it only serves as a proxy to pass the state to its template, Angular provides a nice shortcut to subscribe to state updates directly from templates via the async
pipe. ngFor
in the example below will redraw a list of candidates every time the state is updated.
coffee-election.component.html:
These subscriptions to state updates via async
pipes are automatically cleaned up by the framework upon destroying the component.
Unit testing the store
Testing state modifying store methods is pretty straightforward. It consists of three steps:
- Creating an instance of the tested store and setting up mocked initial state.
- Calling a store’s method the test is testing.
- Asserting the method updated the state correctly.
In practice unit tests to test the store from the Coffee election example look like this:
coffee-election.store.spec.ts:
Conclusion
The purpose of this post was to present how one can leverage the built in features of Angular framework to implement a simple yet powerful state management solution. The provided Coffee election example app is very simple, but the concepts it demonstrates can be used to successfully manage state in much bigger and more complex apps. At Zemanta we used observable store services to implement a rather complex feature and since the experiment worked out great we will continue to use such stores in our app going forward.