Skip to content

Latest commit

 

History

History
477 lines (328 loc) · 45.1 KB

File metadata and controls

477 lines (328 loc) · 45.1 KB
← Previous Next →
Data model and access Go to index Logbook UI and flows

Logbook stores

Before we dive into the workings of the user interface and flows for this simple logbook app we need to understand how the app's state and logic is managed, through stores. As mentioned previously, these stores are the central "hubs" (or "engines") of the app.

State management using NgRx SignalStore

One of the opinionated choices made as part of this curated tech stack is the use of NgRx SignalStore for state management. SignalStore is very well-designed and works hand-in-hand with Angular's Signals system. It's worth familiarizing yourself with both of these technologies before reading on.

Tip

The NgRx docs do a fantastic job of explaining why you would want to use a library like NgRx to manage state. Whilst these docs linked to cover the older (but still relevant) NgRx Store the principles are still applicable to SignalStore. Note that SignalStore is more lightweight and does not follow the Redux pattern, making it a bit simpler to use.

✅ Pattern
As outlined in the Architecture document we highly recommend never accessing databases and external services directly in components (even if you have wrapped access in a data service). Instead, always have a separate layer — stores — to manage all data access, combined with state management and app logic.

The logbook feature consists of the following stores for state management and app logic — all feature-level stores provided at the route-level (and thus available to all components in the routing tree, as singular instances):

  1. ConfigStore — for loading the config object (currently just containing the predefined categories), with realtime updates.
  2. EntriesStore — for loading, paginating and filtering the entries in the logbook, with realtime updates.
  3. EntriesUpdateStore — for one-off operations to create, update and delete entries in the logbook.

Managing streams of realtime data in a store — ConfigStore as an example

Many of the state management examples you see on the web will (rightly) be simple and cover a use case where the data is all in memory, not fetched and persisted to a database. Or if using a database it will show some basic "CRUD" (create, read, update, delete) operations accessing an API and updating the in-memory state after every operation. We focus on a different approach centred around managing streams of realtime data.

Important

Since we use the realtime capabilities of Firestore and Realtime Database we have to think about, and build, a different model of state management: one that manages a stream of data (i.e. connecting and disconnecting it) and syncs the local state with new data as it comes in. Whilst completely separating out reads and updates (loosely based on the Command-query separation (CQS) principle) and relying on the stream to be updated by the backend when relevant updates are made. This is at the core of both the ConfigStore and EntriesStore in this simple example app.

We can rely on the underlying data access services (covered in a previous document) to always provide long-lived observables for the fetched data, which we use to connect to a store's state and manage the overall status of the stream (i.e. disconnected, connected, error, etc.)

Let's look at how this done in the ConfigStore as an example (the same principles apply to the EntriesStore, with an important difference which we'll cover later in this document).

First, we model the TypeScript types for our state:

type DisconnectedState = {
status: 'disconnected';
categories: [];
error: null;
};
type ConnectingState = {
status: 'connecting';
categories: [];
error: null;
};
type ConnectedState = {
status: 'connected';
categories: Config['categories'];
error: null;
};
type ErrorState = {
status: 'error';
categories: [];
error: string;
};
type ConfigState = DisconnectedState | ConnectingState | ConnectedState | ErrorState;
const initialState: ConfigState = {
status: 'disconnected',
categories: [],
error: null,
};

  • There are four possible states: disconnected (the initial state), connecting, connected and error, as defined by the possible status property values.
    • It's possible to have more, but these are the minimum required to manage a stream.
    • We could've stored the whole Config object in the state, but it's often better to "explode", or flatten, the property out into the state object, so we can have finer-grained access to the individual properties (which SignalStore separates out into their own signals).
  • The categories property can only ever have a non-empty value when in the connected state, and we can rely on TypeScript to enforce this.
    • The Config['categories'] type for the non-empty categories property is just a way of saying "use the type of the categories property from the Config type (from the app's shared models file)" — in this case equivalent to string[].
  • The error property can only ever be non-null when in the error state, and we can rely on TypeScript to enforce this.
✅ Pattern
Use TypeScript's discriminated unions to separate out the different possible states and their associated data, based on a status property. This allows us to be quite specific and intentional about all the possible states we can legitimately expect the store to have, and we can lean on the TypeScript compiler to detect unexpected states when updating the state. This also serves as useful self-describing code for future reference.

NgRx SignalStore gives us the signalStore factory function to define a new store. The arguments to this function are the results of "feature factories" that compose the store's state and behavior. For the ConfigStore we have the following structure:

export const ConfigStore = signalStore(
  withState<ConfigState>(initialState),
  withMethods(...),
  withHooks({
    onInit(store) {
      // ...
    },
  }),
);

Collectively, these allow us to define and set up the state, define methods on the store, and provide hooks that run when the store initializes or is destroyed. See the NgRx SignalStore docs for more comprehensive details.

Tip

SignalStore also has withComputed for computed signals, which we use in the EntriesStore (see below). You can even write your own SignalStore "feature" extensions, or use ones made by others, and plug them in.

ngrx-toolkit is an excellent library of extensions for NgRx SignalStore.

We declare our store as:

export type ConfigStore = InstanceType<typeof ConfigStore>;

export const ConfigStore = signalStore();

Note how the type declaration has the same name as the store constant being exported. This additional type declaration is useful when referring to the instance type of the store elsewhere. You'll especially see these instance types used in tests, e.g. ngMocks.get<MyStore>(MyStore) but they can be used anywhere you need to pass an instance of a store (e.g. into a function).

Note

Since the ConfigStore is a feature-level store (provided in the lazily loaded route) we don't configure the signalStore definition with { providedIn: 'root' } (like the AuthStore has, which is global). This tells Angular to only ever instantiate it when it's explicitly provided somewhere first (and then the instance is only actually created when it's injected somewhere, and only available within that provider context).

All the bits in the ConfigStore are geared towards managing and storing the stream of config data from Realtime Database. To trigger this, we call store.manageStream('connect'); in the onInit hook to connect the stream (manageStream is a method defined in the withMethods factory, which we'll cover below).

Important

Where and when you connect and disconnect a stream is an application decision and a very important one — we don't want to prematurely fetch (and keep a realtime connection to) data too early on, and we want to be wary of how long the stream is connected.

🧠 Design decision
In both the ConfigStore and EntriesStore we connect the stream in the onInit hook, which is only triggered when the /logbook feature is loaded in the browser (since these are feature-level stores on a lazily-loaded route).

Alternatively, we could've chosen to connect the stream when the logbook shell or main page components load.

Warning

Stores (and other Angular services) won’t actually instantiate (and thus, in our case here, connect) until they are first injected into a component (regardless of where they are provided). This may catch you out during development, when first building a store and wanting to check if the connection happens (via console logs), before actually using them in the UI — make sure to use inject(MyStore) in a component to trigger the store's instantiation (and get a reference to it).

Note

Whilst both stores do have the ability to disconnect the stream, we don't actually use this here. As far as we can see, there is no way for services provided in lazily-loaded routes to be automatically destroyed / unloaded when you navigate away from the route (there is an open issue on the Angular repo about this) so this means the onDestroy SignalStore hook doesn't ever trigger for our feature-level stores, so we've not bothered to implement this. But also because the internal observables that are a part of an rxMethod are automatically unsubscribed when the store is destroyed anyway.

We've deemed this to be okay for this simple example app, since once you're on the /logbook part of the app (and both config and entries streams are connected) you can't really navigate anywhere else without closing the app (and thus clearing the connections and memory). So this is an acceptable risk.

In a real-world app you would have to consider the lifecycle of the stream, to avoid memory leaks and unneeded long-running data streams. One option could be to disconnect the stream when the shell component is destroyed (by hooking into the component's onDestroy and calling store.manageStream('disconnect');).

Aside: note how we log state changes in the onInit hook:

onInit(store) {
effect(() => logger.log('State:', getState(store)));

This allows us to see the state changes in the console, which can be very useful for debugging and understanding how the store is behaving. Note that it doesn't include state changes from computed signals in the store.

Back to managing the stream: let's look at all the set-up bits we do in the withMethods factory function (for the ConfigStore):

withMethods((store) => {
const configService = inject(ConfigService);
// ---
// Internal methods:
const setConnecting = () => {
const newState: ConnectingState = { status: 'connecting', categories: [], error: null };
patchState(store, newState);
};
const setConnected = (config: Config) => {
const newState: ConnectedState = { status: 'connected', ...config, error: null };
patchState(store, newState);
};
const setDisconnected = () => {
const newState: DisconnectedState = { status: 'disconnected', categories: [], error: null };
patchState(store, newState);
};
const setError = (error: string) => {
const newState: ErrorState = { status: 'error', categories: [], error };
patchState(store, newState);
};
const connectedStream$ = () => {
return configService.getConfig$().pipe(
tapResponse({
next: (config) => setConnected(config),
error: (error) => {
logger.error('Error getting config data:', error);
setError('Unable to fetch config data. Try refreshing the page in a few minutes.');
},
}),
);
};
const disconnectedStream$ = (): Observable<never> => {
return EMPTY.pipe(finalize(() => setDisconnected()));
};
// ---

  • The factory function is passed in an instance of the store that contains everything defined up to that point in the definition of the signal store.
  • We inject the data access service ConfigService (covered in a previous document), to be used later on in the manageStream method.
  • We define some internal functions within the withMethods factory function body, to help us set the store to a particular state.
    • These functions are only accessible to this block of code and are not exposed outside the store.
    • As you can see we are benefiting from the TypeScript types for the different states here — the functions correspond 1:1 to each possible state and only ever allow the store to be in one of these states at a time.
    • patchState is a special function provided by SignalStore that allows us to update the state within the store. The store instance you see here is the one passed into the withMethods factory function (as mentioned).
  • We'll cover the connectedStream$ and disconnectedStream$ internal functions below.
✅ Pattern
Never use patchState outside a SignalStore. Whilst it's currently possible to do so it's not recommended — we want to keep updates to the state centralized to only within the store, either through store methods or side effects from RxJS streams. The latter should ideally call internal updater functions like the ones defined above.

Finally, let's look at the implementation of the manageStream store method — the only public method defined in this store:

return {
manageStream: rxMethod<'connect' | 'disconnect'>(
pipe(
tap((action) => logger.log(`manageStream - action = ${action}`)),
tap((action) => (action === 'connect' ? setConnecting() : null)),
switchMap((action) => {
if (action === 'connect') {
return connectedStream$();
} else {
return disconnectedStream$();
}
}),
),
),
};

✅ Pattern
Always manage a stream in a store through a single method (manageStream('connect' | 'disconnect')) as this allows us to manage the internal observable(s) in one place, switching and deleting them as needed.

If we had separate connect and disconnect store methods then we'd need to capture any observables in internal variables and manage the subscription manually, instead of leveraging flattening operators like switchMap, which is one of the benefits of using RxJS for this use case.

Tip

You may become confused with the multiple use of the word "stream", below — we mention "RxJS streams" and "data streams". Ultimately, these are the same thing — RxJS streams (using observables), combined in a way to allows us to control what happens within a higher level stream — we use the switchMap RxJS operator to switch the underlying stream used in the single higher level RxJS stream created for the manageStream method.

  • The rxMethod factory is a special function provided by NgRx SignalStore that allows us to define a reactive method managed by SignalStore, creating a single high level RxJS stream for all inputs to that store method. This is the key to allowing us to manage the stream of data from Realtime Database (or Firestore).
    • Think of it loosely like this: SignalStore will make sure to keep the method's stream subscription "alive" when the store is created and clean it up when the store is destroyed. Whilst it's alive you can push values to this stream (by calling the store method) and use all of RxJS's operators to transform, switch, manage etc. the stream.
  • Our method takes in either 'connect' or 'disconnect' as a way of signalling intent (aka an action).
    • Depending on which is passed in we either connect or disconnect from an underlying data stream. But either way SignalStore keeps the high level stream alive (even if it's not doing anything at the beginning, or when disconnected).
    • There is only ever one instance alive of this manageStream RxJS stream — we choose when it connects and disconnects based on the method call. And we choose what happens inside this stream.
  • When connecting:
    • We first set the store to a connecting state using the setConnecting method (mentioned above).
    • Then we switch to a connected stream (covered below).
  • When disconnecting:
    • We just switch to a disconnected stream (covered below).

Note

switchMap (and other flattening operators) will automatically unsubscribe from a previous stream. So for example, when we connect then disconnect, the previous data stream from the connection will be unsubscribed from and cleaned up (thus stopping any further data from coming in).

We've chosen to break out the connected and disconnected streams into separate internal helper functions (as we saw earlier, and repeated below):

const connectedStream$ = () => {
return configService.getConfig$().pipe(
tapResponse({
next: (config) => setConnected(config),
error: (error) => {
logger.error('Error getting config data:', error);
setError('Unable to fetch config data. Try refreshing the page in a few minutes.');
},
}),
);
};
const disconnectedStream$ = (): Observable<never> => {
return EMPTY.pipe(finalize(() => setDisconnected()));
};

This makes it easier to reason about what happens in the manageStream method (i.e. doesn't clutter it with more implementation details).

✅ Pattern
Break out inner observables into separate functions or variables, where possible. When using the RxJS pipe with operators, to perform stream processing, it's easy to get into a complex-looking mess. One way to make things readable is to split things out, just like you would for long and complex-looking functions.
  • The connectedStream$ function is where we actually connect to the data stream from Realtime Database, using the observable returned by ConfigService#getConfig$. We chain it with some RxJS operators so that we can process the stream and take action on the data.
    • When new data is emitted from the data stream (i.e. at initial load, or when realtime updates come in) we call the setConnected internal function which pulls out the categories and stores them in the state (and transitions to a connected state).
    • We also handle any errors that come back from the service and set the store to an error state (more on error handling, below).
  • The disconnectedStream$ function is where we return an empty observable (using the special EMPTY observable imported from RxJS) — i.e. one that emits no values. We use the finalize operator to set the store to a disconnected state.
✅ Pattern
Use the special tapResponse operator from @ngrx/operators to handle responses. It's a really useful convenience operator that ensures that you handle errors in streams, which is important.

With all this in place, the ConfigStore automatically connects to the Realtime Database (via the ConfigService), maintains a realtime stream, handles errors and updates the state as new data comes in. The store provides a single source of truth for the config data and allows consumers of the store to inspect and use this state to drive UI flows. The same principles apply to the EntriesStore, which build on this structure to provide more capabilities (covered below).

A note about error handling

Good error handling is key to a robust app and smooth user experience. Let's face it, errors happen — bugs creep in, internet connections drop, services break down, etc. We need to be prepared for this and handle it gracefully, for the sake of our users.

In this simple example app, we provide basic and minimal error handling, resorting to generic error messages or surfacing lower level errors. This is good enough for now, but in a real-world app it's important to consider more advanced error handling patterns like mapping errors to useful user messages, retrying operations, exponential backoff, etc., depending on needs.

✅ Pattern
A key minimum in error handling when using RxJS observables is: ALWAYS catch errors in the pipe chain — either using the catchError operator directly or the previously mentioned tapResponse operator. Otherwise, observables will unexpectedly finish and stop emitting any new values, which is often not what's intended.
✅ Pattern
When an error is detected, we usually want to put that store into an error state, as we've seen in the ConfigStore and EntriesStore. This allows components to react to this state and display appropriate messaging in the UI.

Note

So far we've cleared out any internal data within the stores, when an error occurs, as we've chosen to only show a prominent error state on the logbook page (covered in a later document) and no data. This may not be the best approach in all cases — sometimes it may be okay to keep the previous (potentially stale) data in the store whilst showing the error message.

There is one important caveat to the way we've handled errors in the ConfigStore, as implied by the note above: when an error occurs we clear out the categories list. For now, it’s not critical to the app's functionality — the worst case is you have to create entries without a category, or you won't have any filtering options, which is not ideal, but also not a showstopper. In the advanced app we’ll see how to handle a case where this is critical to the app’s functionality, and how to improve the UX for this.

The EntriesStore

The EntriesStore is similar to the ConfigStore in that it manages a stream of data, but it has a few key differences:

  • The connecting of the data stream is more involved as it needs the user ID of the logged in user before it can connect the stream of entries from Firestore.
  • The list of entries is managed in the state using SignalStore's entity management capabilities.
  • We have some extra computed fields to derive state from the base state.
  • It provides more capabilities like pagination and filtering.

Getting the user ID when connecting the entries stream

Before we get a connected stream of entries from Firestore we need to know the user ID of the logged in user. Here is the implementation of the manageStream method in the EntriesStore:

manageStream: rxMethod<'connect' | 'disconnect'>(
pipe(
tap((action) => logger.log(`manageStream - action = ${action}`)),
tap((action) => (action === 'connect' ? setConnecting() : null)),
switchMap((action) => {
if (action === 'connect') {
return authStore.user$.pipe(
map((user) => user?.id),
distinctUntilChanged(),
combineLatestWith(toObservable(store._pageCursor), toObservable(store.filters)),
switchMap(([userId, pageCursor, filters]) => {
if (userId) {
// We fetch one extra to check if there's more for a next page
const pageSize = PAGE_SIZE + 1;
return connectedStream$(userId, pageSize, pageCursor, filters);
} else {
return disconnectedStream$();
}
}),
);
} else {
return disconnectedStream$();
}
}),
),
),

  • We are now switchMap-ing twice, first on the user$ observable from the AuthStore (which is injected earlier in the withMethods factory function) and then — as long as we have a non-null user — on the data stream observable from EntriesService.
    • This is so we can listen out for when the user changes (i.e. logs in or out) and connect or disconnect the stream accordingly — if the user becomes null then we have to disconnect the entries stream.
  • The distinctUntilChanged() operator here is used to ensure we ignore when the same user (with the same ID) is emitted multiple times (which can happen, for example, when the user token is refreshed).
  • We'll cover the pagination and filtering aspects below.

Entries entity management

You may notice the state object of the EntriesStore doesn't define the list of entries as part of its type. This is because we use SignalStore's entity management to layer in the list of entries into the state, managed using a special optimized entity management system.

We start with a config object that will be reused whenever we need to refer to the entity collection:

const entriesEntityConfig = entityConfig({
entity: type<EntryDoc>(),
collection: '_entries', // Make it private
});

Then, in the signal store definition, we use the withEntities factory function to add the entity management for the entries collection to the state:

export const EntriesStore = signalStore(
withState<EntriesState>(initialState),
withEntities(entriesEntityConfig),
withComputed((store) => {

As mentioned in the docs, the withEntities(…) factory function tells SignalStore to add three properties to the state: ids, entities and entityMap (which may be named differently based on the specified name in the config object), where entities is computed from the other two. This ensures the entries are normalized, making it faster to update the list, which is a good pattern to follow when dealing with lists of data.

Note

Here, we are using SignalStore's special syntax for private members to ensure that the whole entity collection is private and hidden to consumers of the store. We expose the actual list of entries in the state as a computed signal (which we'll cover below).

We then use various entity management helper functions in our internal updater functions to manage the entity collection together with the rest of the state:

const setDisconnected = () => {
const newState: DisconnectedState = {
status: 'disconnected',
currentPage: null,
filters: {},
error: null,
_pageCursor: { startAt: null, endAt: null },
};
patchState(store, removeAllEntities(entriesEntityConfig), newState);
};
const setConnecting = () => {
const newState: ConnectingState = {
status: 'connecting',
currentPage: 1,
filters: {},
error: null,
_pageCursor: { startAt: null, endAt: null },
};
patchState(store, removeAllEntities(entriesEntityConfig), newState);
};
const setConnected = (entries: EntryDoc[]) => {
const newState: Partial<ConnectedState> = { status: 'connected', error: null };
patchState(store, setAllEntities(entries, entriesEntityConfig), newState);
};
const setError = (error: string) => {
const newState: ErrorState = {
status: 'error',
currentPage: null,
filters: {},
error,
_pageCursor: { startAt: null, endAt: null },
};
patchState(store, removeAllEntities(entriesEntityConfig), newState);
};

Notice the functions removeAllEntities and setAllEntities, and how we chain these with the updating of the other state, in the patchState call.

It's also possible to do finer-grained additions, updates and removals from the list, which is where this entity management really shines.

✅ Pattern
Try to always use SignalStore's entity management when storing a list of items in the state (unless the list is small and you know it won't grow unbounded).

You can even use it multiple times for different lists in the same store, as described in the official docs

Computed state

We use the withComputed factory function to derive state from the base state in the store:

withComputed((store) => {
return {
entries: computed(() => store._entriesEntities().slice(0, PAGE_SIZE)),
hasPreviousPage: computed(() => {
const currentPage = store.currentPage();
return currentPage && currentPage > 1;
}),
hasNextPage: computed(() => {
const currentPage = store.currentPage();
const allEntities = store._entriesEntities();
return currentPage && allEntities.length > PAGE_SIZE;
}),
};
}),

  • As you can see, this uses Angular signal's computed function, which only recalculates when any dependent signals change.
  • As mentioned in the entity management section, above, there is an internal entities collection signal (in our case _entriesEntities) in the state that could be used to access the entries loaded. However, we want to provide a better named property — entries, and we implement a bit of a trick to make pagination work properly, so we define our own entries signal here which is the one that consumers of the store should use.
    • We'll cover this pagination trick, and the hasPreviousPage and hasNextPage computed signals, below.

Pagination

✅ Pattern
Whenever you store an arbitrary list of items in a database consider using pagination, both for performance and user experience reasons. Especially if that list can grow unbounded, as is the case with the logbook entries.

Pagination ensures you only fetch the particular page (or window) of data required to show (or process) in the UI.

As part of the spec of the simple example app we want to show how pagination can be implemented in this tech stack. From a user's perspective, we want to show items a few at a time, with the ability to go to previous and next pages (where applicable). We use Firestore's startAfter and endAt query cursors to achieve this. These allow us to use a particular document field value (or a document reference itself) as a cursor to start or end the query at.

Note

Pagination is a surprisingly complex topic, especially when dealing with realtime data. The list shown can quickly become out of date as new items are added, updated or deleted in the database. This can make for some weird edge cases, which we'll show here.

But note that there is no one-size-fits-all solution to pagination, and it's often a trade-off between performance, user experience and complexity, like most things in software development!

Caution

You may have come across a style of pagination called offset-based pagination, where you use limit and offset in your query to determine the exact page to fetch. It is highly recommended to avoid this as it often suffers from performance and cost issues. Instead, use cursor-based pagination (also known as keyset pagination), which is what we use here for Firestore.

Here's a good general article on the topic: https://use-the-index-luke.com/no-offset.

Note that Firestore does offer offset-based pagination in the admin library, but be aware of the cost implications — you are charged for a read operation on ALL the documents up to the offset + limit, even if only the last X are returned back, and then multiply this by the number of pages you fetch over time (see reference 1 and reference 2).

Recall that we are working with a live data stream of entries, which the backend updates for us in realtime. We need to switch this data stream to a particular paginated (and filtered) query every time a pagination parameter (or filter) is changed by the user.

First, we capture the pagination state in the store, using the properties currentPage and _pageCursor:

type DisconnectedState = {
status: 'disconnected';
currentPage: null;
filters: EmptyEntriesFilters;
error: null;
_pageCursor: EmptyPageCursor;
};
type ConnectingState = {
status: 'connecting';
currentPage: 1;
filters: EmptyEntriesFilters;
error: null;
_pageCursor: EmptyPageCursor;
};
type ConnectedState = {
status: 'connected';
currentPage: number;
filters: EntriesFilters;
error: null;
_pageCursor: PageCursor;
};
type ErrorState = {
status: 'error';
currentPage: null;
filters: EmptyEntriesFilters;
error: string;
_pageCursor: EmptyPageCursor;
};
type EntriesState = DisconnectedState | ConnectingState | ConnectedState | ErrorState;
const initialState: EntriesState = {
status: 'disconnected',
currentPage: null,
filters: {},
error: null,
_pageCursor: { startAt: null, endAt: null },
};

  • The currentPage is what gets updated to trigger back and forth between pages of entries, via store methods (below).
  • We use the _pageCursor private property to store the startAt and endAt values for the Firestore query.
  • We're leveraging TypeScript types to strongly define what the expected values are for these properties in the different possible states.

Here are the relevant type definitions used for the _pageCursor property:

export type EmptyPageCursor = { startAt: null; endAt: null };
export type PageCursor =
| EmptyPageCursor
| { startAt: Timestamp; endAt: null }
| { startAt: null; endAt: Timestamp };

Note

When an error occurs in the connected stream, the setError internal function is called, where we reset the pagination properties and empty them out.

🧠 Design decision
Since we know that the entries list in a logbook is only ever updated by a single user, we can safely assume that it's extremely unlikely for multiple entries to exist with the exact same timestamp value (for the same user). And since we use the timestamp for ordering, we can safely use it for the pagination cursor value here.

Whilst this works well for our particular case, it's important to consider how you want to drive the cursor-based pagination in your app. If many people are updating a single list at the same time then using the timestamp could cause missing items when going back and forth.

Note

The _pageCursor property is an example of state that is internal to a store — it's not expected to be used by consumers of the store. We make use of SignalStore's private members syntax to ensure this.

For now, we're hardcoding the page size as an internal constant of the store module file (towards the top of the file):

Tip

This page size value could have been something configurable by the user, by storing it in the state itself, providing a method to change it, and listening out for changes to trigger a new Firestore query.

A key question when implementing pagination: how do we know if there are more pages of items to show? We want to avoid getting the full count of documents in a Firestore collection, as this can be costly (even if you use Firestore's new count() aggregation method), and it's yet another variable to manage and keep up to date.

This is where we use a common "trick" (as alluded to earlier): we always try to fetch N+1 items, and we use the presence of the N+1-th item to tell us there's more. We have to make sure not to show this N+1-th item in the UI, and to start the next page at this N+1-th item.

Let's revisit the keys bits of the manageStream method in the EntriesStore, for pagination (and filtering):

combineLatestWith(toObservable(store._pageCursor), toObservable(store.filters)),
switchMap(([userId, pageCursor, filters]) => {
if (userId) {
// We fetch one extra to check if there's more for a next page
const pageSize = PAGE_SIZE + 1;
return connectedStream$(userId, pageSize, pageCursor, filters);
} else {
return disconnectedStream$();
}
}),

  • We convert the store state signals for _pageCursor and filters into observables so we can listen out for any changes to these values and re-trigger the query.
    • We use the toObservable helper function provided by the Angular signals' RxJS Interop package.
    • We use the combineLatestWith RxJS operator to combine the original input in the chain (the user ID) with the data from these observables and listen out for changes to all three – any change to any of these will trigger a new emission of this stream, and thus re-trigger the query.
  • We fetch N+1 items by incrementing the page size we send to the query by 1.
  • We then pass these values into the connectedStream$ helper function, which delegates on to the data access service.

Important

When the stream emits new entries, we set the entities list in the store to all the entries received (which may be of length PAGE_SIZE + 1), in the setConnected internal function, and so — as mentioned in the computed signals section — we derive the entries list computed signal from this, by taking the first PAGE_SIZE items.

We need to capture this original list of entries (with potentially N+1 items), as we'll need it to determine if there's a previous or next page of entries to show, as described below.

✅ Pattern
Always try to derive state from underlying data using computed signals, rather than creating new variables that need to be kept in sync manually.

For the case described above, another approach could've been to set an internal flag to indicate if N+1 items were received, and then only keep items up to the page size. However, it's usually better to derive flags (and other states) like this from underlying data rather than manually keeping it in sync, as it's more error prone to do this manually. Note that we would also need to store the timestamp values of the first and last documents too, as these are needed for the cursor passed into to subsequent queries (when going back and forth between pages).

To determine whether there is a previous or next page of entries to show, we define hasPreviousPage and hasNextPage computed signals, as shown in the computed signals section, above. These are used in the UI to show the pagination buttons.

  • The hasPreviousPage computed signal is simple, in that all it does is check if the current page is not the first page, in which case we can assume there's a previous page.
  • The hasNextPage computed signal is more involved, as it has to check if the N+1-th item is present in the list of underlying entities, by checking if it has more entries than the page size.

We then provide store methods to go back and forth between pages (these are defined within the withMethods factory function):

previousPage(): void {
const currentPage = store.currentPage();
const hasPreviousPage = store.hasPreviousPage();
if (currentPage && hasPreviousPage) {
const allEntities = store._entriesEntities();
const lastEntry = allEntities[allEntities.length - 1];
if (lastEntry) {
patchState(store, {
currentPage: currentPage - 1,
_pageCursor: { startAt: null, endAt: lastEntry.timestamp },
});
}
}
},
nextPage(): void {
const currentPage = store.currentPage();
const hasNextPage = store.hasNextPage();
if (currentPage && hasNextPage) {
const allEntities = store._entriesEntities();
const lastEntry = allEntities[allEntities.length - 1];
if (lastEntry) {
patchState(store, {
currentPage: currentPage + 1,
_pageCursor: { startAt: lastEntry.timestamp, endAt: null },
});
}
}
},

  • We use the computed signals mentioned above to determine if we can go back or forth.
    • If we can't, we just ignore the method call.
  • We update the currentPage and _pageCursor properties using the patchState helper function.
    • We get the relevant timestamp value from the first or last document in the list of entities, and use this to set the _pageCursor property.
  • Recall that in the manageStream method we are listening out for any changes to these state properties, and re-triggering the query when they change.

These methods are used by consumers of the EntriesStore to drive pagination in the UI (as we'll see in a later document).

Filtering by category

Building on what we've seen for pagination, we also want to allow the user to filter the entries by category (working together with the pagination).

We have a filters object in the state to capture the filter state, as shown previously.

The relevant types for this property are:

export type EmptyEntriesFilters = EmptyObject;
export type EntriesFilters = EmptyEntriesFilters | { category: string | null };

🧠 Design decision
We have modelled three possible filter states here:

  1. No filters — i.e. show all entries.
  2. A category filter is set to null — i.e. show entries with no category set.
  3. A category filter is set to a non-null value — i.e. show entries with that category set.

Given these filtering states, it's important that the underlying data is validated and stored properly — ideally, we never want the category field of the entries documents (in Firestore) to be an invalid category. If no category is set, the expectation is that an empty category is ALWAYS null, not missing and not an empty string. This is handled at the data service level, by ensuring we normalize the value stored for the category field of an entry:

// Make sure we never store `undefined` or empty string in the `category` field
const category =
typeof data.category === 'undefined' || data.category === '' ? null : data.category;

In a later document we'll see how the frontend ensures that only a category from the config is used on an entry.

Important

For the purposes of this simple example app we don't validate the category field value in the backend (i.e. server-side validation). We have frontend protections in place, but technically someone could make a direct API call and set this to any value they want. We've deemed this to be an acceptable risk for now, as the worst case is entries don't show up when filtering.

But it's important to consider this on a case-by-case basis in a real-world app, and to consider any security implications.

We've seen in the pagination section (above) how we listen out for changes to the state's filter property and re-trigger the query when it changes.

We provide a store method to set this filter (defined within the withMethods factory function):

setCategoryFilter(category: string | null | undefined): void {
if (typeof category === 'undefined') {
patchState(store, {
filters: {},
currentPage: 1,
_pageCursor: { startAt: null, endAt: null },
});
} else {
patchState(store, {
filters: { category },
currentPage: 1,
_pageCursor: { startAt: null, endAt: null },
});
}
},

  • We're using undefined as a special value to indicate no filter.
  • We either clear out the whole filters state, or set it to the category value passed in (null or string).
    • Note: currently this won't work out-of-the-box if we want to support other filters apart from category, since we're overwriting the whole filters state — this function would need to be adapted to support other filters.
  • We use the patchState helper function to update the state.
  • Note how we reset pagination when the filter changes.
    • This is important as we are fetching a whole new subset of data and the previous pagination state would be out of date for this subset.
    • It also provides a potentially better user experience, as the user is taken back to the first page of the filtered list.

This method is used by consumers of the EntriesStore to drive filtering by category in the UI (as we'll see in a later document).

The EntriesUpdateStore

It might seem overkill to use a state management library to wrap create, update and delete operations. But these operations still need some form of state to capture processing status and errors, and potentially more (like the ID of a newly created data item, if needed).

✅ Pattern
Use NgRx SignalStore to wrap create, update and delete operations in a store (or multiple stores), so that you can capture processing and error states (and potentially other useful state), and have a consistent way of managing app state and logic.
🧠 Design decision
For this simple example app, we've chosen to keep it simple and use one store for all creating, updating and deleting of logbook entries — the EntriesUpdateStore. This is provided at the router-level (so one instance across all components within that route component tree).

We've also chosen not to model the state using discriminated union types (like we do for managing streams, to represent the different possible states based on a status field). Instead, we keep it very simple and just have a processing flag and error field.

This simple approach is good enough for our use case here, and is usually a good approach to start off with.

Warning

Because the EntriesUpdateStore is provided at the router-level, Angular will create and maintain one instance in memory and make it available to all components in the routing tree. This means all operations (of all types) share the same state, regardless of which component calls them, and triggering an operation clears out the previous state from the last operation called.

In reality, this is only a problem if you a) have multiple components reacting to the processing and error flags and causing excess UI updates, or b) if operations are expected to happen very frequently and thus run in to timing and race conditions, which we don't do or have in this simple example app.

Tip

An alternative is to split the separate operations into individual stores, so we can have finer grained control (e.g. separate instances per component, and separate processing flags and error messages). You can then also model the state in a more advanced way, e.g. using discriminated union types.

Let's look at the EntriesUpdateStore step by step.

The state is defined simply as:

type EntriesUpdateState = {
processing: boolean;
error: string | null;
};
const initialState: EntriesUpdateState = {
processing: false,
error: null,
};

  • We have a processing flag to indicate if an operation is in progress.
  • We have an error field to capture any error state.

The withMethods factory function starts with:

withMethods((store) => {
const authStore = inject(AuthStore);
const entriesService = inject(EntriesService);
// ---
// Internal methods:
const setProcessing = (processing: boolean) => {
patchState(store, { processing, error: null });
};
const setError = (error: string) => {
patchState(store, { processing: false, error });
};
// ---

  • We inject the AuthStore and EntriesService to be used in the methods.
    • Similar to the EntriesStore, we need the AuthStore to get the user ID, so we can perform the operations for a specific user only.
  • We define a couple of internal functions to set the store to a particular state.
    • Note that we clear the error field whenever setProcessing is called, as we assume that a new operation is starting fresh, and any previous error is no longer relevant.

Finally, we have the create, update and delete store methods, that consumers of the store can use to carry the operations out:

create: rxMethod<{ data: NewOrUpdatedEntryInput }>(
pipe(
tap((params) => logger.log('create - inputs:', params)),
tap(() => setProcessing(true)),
concatMap(({ data }) => {
const user = authStore.user();
if (user) {
return entriesService.createEntryDoc$(user.id, data);
} else {
setError('Not logged in');
return EMPTY;
}
}),
tapResponse({
next: () => setProcessing(false),
error: (error) => {
logger.error('create - error:', error);
setError(
'Something went wrong when creating a new log entry. Please try again later.',
);
},
}),
),
),
update: rxMethod<{ entryId: string; data: NewOrUpdatedEntryInput }>(
pipe(
tap((params) => logger.log('update - inputs:', params)),
tap(() => setProcessing(true)),
concatMap(({ entryId, data }) => {
const user = authStore.user();
if (user) {
return entriesService.updateEntryDoc$(entryId, data);
} else {
setError('Not logged in');
return EMPTY;
}
}),
tapResponse({
next: () => setProcessing(false),
error: (error) => {
logger.error('update - error:', error);
setError('Something went wrong when updating a log entry. Please try again later.');
},
}),
),
),
delete: rxMethod<string>(
pipe(
tap((entryId) => logger.log(`delete - entryId = ${entryId}`)),
tap(() => setProcessing(true)),
concatMap((entryId) => {
const user = authStore.user();
if (user) {
return entriesService.deleteEntryDoc$(entryId);
} else {
setError('Not logged in');
return EMPTY;
}
}),
tapResponse({
next: () => setProcessing(false),
error: (error) => {
logger.error('delete - error:', error);
setError('Something went wrong when deleting a log entry. Please try again later.');
},
}),
),
),

  • All of these are defined using the rxMethod factory function.
    • This gives us a single RxJS stream per operation (create + update + delete).
  • Before each operation is carried out, we set the processing state to true.
  • We then map to a new observable that we get from the EntriesService, to carry out the database operation.
    • Note that these are not long-lived observables, but are created and completed per operation.
  • Notice how we use the concatMap flattening operator here — this ensures that the next operation is only carried out when the previous one has completed first. This way, we can guarantee that all operations are carried out, and in order.
  • We use the tapResponse operator (as seen previously) to handle both the success and error cases, setting the state accordingly.

Important

Firestore operations from the client-side (i.e. the JavaScript SDK) use optimistic concurrency control (aka optimistic updates) — they assume that the operation will succeed and update data in memory first, before the operation has actually been confirmed by the server. This means the UI gets updated before the operation is actually confirmed to be successful. It's important to be aware of this — it's possible for the operation to fail, in which case the data in memory is reverted, and the operation is essentially reversed.

For the simple example app, we don't do anything special to handle this, given the simple nature of the app.

Note

We only make use of the concatMap operator in this particular case because it's the safest option: previous operations never get cancelled and always run in order, preventing race conditions (e.g. where the error state gets updated by different operations but out of order).

However, there are other flattening RxJS operators — we've used switchMap elsewhere (which cancels the previous stream) and there's also mergeMap and exhaustMap. For this simple example, it would be overkill to use these as it's highly unlikely that any of these operations will be normally carried out by the user in such quick succession that race conditions hit. concatMap is a good default choice for operations like these.

Tip

There is a minor bug (edge case) in the current implementation. Can you spot it?

Hint: it's to do with the the processing flag.

This completes the look at the stores for the logbook feature. Next, we look at the UI and flows that use these stores.


← Previous Next →
Data model and access Go to index Logbook UI and flows