@genesislcap/foundation-store
TypeScript icon, indicating that this package has built-in type declarations

14.173.2 • Public • Published

Genesis Foundation Store

lerna TypeScript

foundation-store leverages the DI container to provide a preformat, decoupled and testable way to manage application state that adheres to our best practices. Using foundation-store is completely optional, as you may decide that your application doesn't warrant a store, or that you would prefer to use a store you are more familiar with. The system is flexible, so you can use whatever you like to handle application level state management, but you should be mindful of degrading performance.

Background

Client apps today manage application state in different ways. These apps might leverage common third party stores likes Redux, or use none at all, peppering @attr and @observable properties in different classes, and at various levels of DOM hierarchy. With the latter business logic might start creeping into our components, every component becomes smart instead of being dumb, providing shared access to data becomes difficult, tests require lots of mocks, things get hard to refactor etc. We should aim to lift state where possible, and it makes sense to do so.

We need to remember that although our components will be used in Genesis applications, they may also be used in isolation in third party client applications.

Migration

Hopefully you're keen to try foundation-store, and have used solutions like Redux in the past. If so you may find the following "Solution X" to foundation-store terminology migration guide beneficial.

Redux

  • Action. Translates to a standard CustomEvent. These event types are defined in a store's EventDetailMap. Here's the StoreRootEventDetailMap for example.
  • Dispatch. Use the component's $emit method in conjunction with the EventEmitter mixin to strongly type it with store event maps.
  • Action Creator. Create the CustomEvent.detail however and whenever you like. When you're ready, emit the event and detail pairing as per an EventDetailMap via the component's $emit api, which in turn creates and sends the CustomEvent.
  • Reducer. Use the store's createListener method to create a synchronous event listener which you can commit values to the store from. These listeners only receive events, so new values may come from CustomEvent.detail payloads, and / or reading from the store itself which these handlers are members of.
  • Effect. Use the store's createAsyncListener method to create an async event listener which can run Side Effects. Similar to the Reducer context above, however you should NOT commit values to the store in these, but instead emit outcome events, ie. success / failure, which can be handled by synchronous listeners.
  • Slice. A store fragment. A part of the store with a specific purpose, domain model.
  • Selector. A simple getter on a store fragment.

Setup

Create a root store.ts file somewhere, for example ./store/store.ts. This will be the root store for the application, which may consist for other store fragments. Each fragment could be considered as a domain, with a single purpose. This setup allows us to isolate data and provide the component trees access to only the data they really need to function.

Example root store: (insert see examples link)

import {CustomEventMap, EventListenerMap, registerEmitter} from '@genesislcap/foundation-events';
import {
  AbstractStoreRoot,
  StoreRoot,
  StoreRootEventDetailMap,
  registerStore,
} from '@genesislcap/foundation-store';
import {observable, volatile} from '@microsoft/fast-element';
import {DesignSystem} from './designSystem';
import {Position} from './position';
import {Trades} from './trades';

/**
 * 1: Define any store custom event details for more complex payloads.
 * See https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail
 */
export interface StoreZooEventDetail {
  zoo: Animal[];
  location: string;
}

/**
 * 2: Define store event to event detail map.
 * For the root store this should be a union of StoreRootEventDetailMap.
 */
export type StoreEventDetailMap = StoreRootEventDetailMap & {
  'store-foo': void;
  'store-bar': boolean;
  'store-zoo': StoreZooEventDetail; // < details with more than one property
  'store-zoo-animals': Animal[];
}

/**
 * 3: Extend built in event maps so that addEventListener/removeEventListener are aware of our events for code completion
 */
declare global {
  interface HTMLElementEventMap extends CustomEventMap<StoreEventDetailMap> {}
}

/**
 * 4: Define internal event to event detail map.
 */
type InternalEventDetailMap = {
  'store-zoo-success': SomeResponse;
  'store-zoo-error': Error;
}

/**
 * 5: Define entire readonly store made up of store fragments and or additional properties.
 */
export interface Store extends StoreRoot {
  /**
   * Store properties
   */
  readonly prop1: number;
  readonly prop2: number;
  readonly someToggle: boolean;
  readonly derivedData: number;
  readonly volatileDerivedData: number;
  /**
   * Store fragments
   */
  readonly designSystem: DesignSystem;
  readonly notifications: Notifications;
  readonly positions: Positions;
  readonly trades: Trades;
  /**
   * Store event handlers
   */
  onFooEvent(event: CustomEvent<void>): void;
  onBarEvent(event: CustomEvent<boolean>): void;
  onZooEvent(event: CustomEvent<StoreZooEventDetail>): void;
}

/**
 * 6: Define the default implementation
 */
class DefaultStore extends AbstractStoreRoot<Store, StoreEventDetailMap, InternalEventDetailMap> implements Store {
  /**
   * Store properties
   */
  @observable prop1: number = 10;
  @observable prop2: number = 20;
  @observable someToggle: boolean = true;

  constructor(
    /**
     * 7: Inject any store fragments
     */
    @DesignSystem readonly designSystem: DesignSystem,
    @Notifications readonly notifications: Notifications,
    @Positions readonly positions: Positions,
    @Trades readonly trades: Trades,
  ) {
    super(...arguments);

    /**
     * 8: Listeners not on the public interface can be created anonymously if you prefer
     */
    this.createListener<SomeResponse>('store-zoo-succes', (detail) => {
      const {prop1, prop2, ...rest} = detail;
      this.commit.prop1 = prop1;
      this.commit.prop2 = prop2;
    });
    this.createErrorListener('store-zoo');
  }

  /**
   * 8: Define your event listeners as per the interface. Please ensure you do so using arrow functions to aid binding.
   * These handlers can be async if you would like to do some async work in them. We suggest you don't commit store
   * mutations in async functions, instead raise an event which you can handle synchronously and commit from there so
   * things are tracked correctly.
   */
  onFooEvent = this.createListener('store-foo', detail => {...});
  onBarEvent = this.createListener<boolean>('store-bar', detail => this.commit.someToggle = detail); // < commit values to the store synchronously
  onZooEvent = this.createAsyncListener<StoreZooEventDetail>('store-zoo', async (detail) =>
    this.invokeAsyncAPI(
      async () => this.someAsyncTask(detail), // < likely an injected service,
      'store-zoo-error',
      'store-zoo-success'
    )
  );

  /**
   * 9: Create getters for common derived data needs, similar to selectors in the Redux sense. These however do not
   * need any special code as they are computing based on properties that are already observable. Derivied data with
   * branching code paths needs to be marked as volatile.
   */

  get derivedData(): number {
    return this.prop1 * this.prop2;
  }

  @volatile
  get volatileDerivedData() {
    return this.someToggle ? this.prop1 * this.prop2 : this.prop1 + this.prop2;
  }
}

/**
 * 10: Register the store which defines the DI key using the interface
 */
export const Store = registerStore<Store>(DefaultStore, 'RootStore');

Your root store is now ready to be injected into your application. Hopefully the above gives you a good idea of general store setup. The example might look a bit verbose, but in reality you can write a small store fragment in 20+ lines of code. For example:

import {CustomEventMap} from '@genesislcap/foundation-events';
import {AbstractStore, Store, registerStore} from '@genesislcap/foundation-store';
import {observable} from '@microsoft/fast-element';

export type TradeEntryUIEventDetailMap = { 'trade-entry-ui-open': boolean }

declare global {
  interface HTMLElementEventMap extends CustomEventMap<TradeEntryUIEventDetailMap> {}
}

export interface TradeEntryUI extends Store {
  readonly isOpen: boolean;
  onOpen(event: CustomEvent<boolean>): void;
}

class DefaultTradeEntryUI extends AbstractStore<TradeEntryUI, TradeEntryUIEventDetailMap> implements TradeEntryUI {
  @observable isOpen: boolean;
  onOpen = this.createListener<boolean>('trade-entry-ui-open', detail => this.commit.isOpen = detail);
}

export const TradeEntryUI = registerStore<TradeEntryUI>(DefaultTradeEntryUI, 'TradeEntryUI');

Store events

'store-connected'

In your main application class, likely ./src/main/main.ts, you need to fire a 'store-connected' event in-order to fully initialise the store.

// ./main/main.ts

type EventMap = StoreEventDetailMap & {...}; // < whatever other events you allow your custom element to emit

/**
 * MainApplication
 * 
 * @fires store-connected - Fires a custom 'store-connected' event after connection to connect the store.
 * @fires store-ready - Fires a custom 'store-ready' event after connection to ready the store.
 * @fires store-disconnected - Fires a custom 'store-disconnected' event after disconnection to unbind the store.
 * 
 * @public
 */
@customElement({ name, template, styles })
export class MainApplication extends EventEmitter<EventMap>(FASTElement) {
  /**
   * @public
   */
  @Store store: Store; // < injected root store

  /**
   * @public
   */
  connectedCallback() {
    super.connectedCallback();
    this.addEventListeners();
    this.readyStore();
  }

  /**
   * @public
   */
  disconnectedCallback() {
    super.disconnectedCallback();
    this.removeEventListeners();
    this.disconnectStore();
  }

  /**
   * @internal
   */
  protected addEventListeners() {
    this.addEventListener('store-connected', this.store.onConnected);
  }

  /**
   * @internal
   */
  protected removeEventListeners() {
    this.removeEventListener('store-connected', this.store.onConnected);
  }

  /**
   * @internal
   */
  protected readyStore() {
    this.$emit('store-connected', this);
    /**
     * Do some other work if needed.
     */
    this.$emit('store-ready', true);
  }

  /**
   * @internal
   */
  protected disconnectStore() {
    this.$emit('store-disconnected');
  }
}

The 'store-connected' event handler needs to be explicitly bound. When the root store handles 'store-connected', it auto binds all the store event listeners to the rootElement.

At this point you can start emitting strongly typed store events, and they will be handled by their corresponding store. See EventEmitter for more information.

'store-ready'

We've created an explicit store ready event to indicate when the store is ready to use, as you may wish to do some additional work upfront. It's not a hard requirement to emit this, but is considered best practice. If you've no work to do, you can just emit this right after 'store-connected'.

// ./main/main.ts

this.$emit('store-connected', this);
/**
 * Do some other work if needed.
 */
this.$emit('store-ready', true);

'store-disconnected'

Emitting 'store-disconnected' will remove all the previously bound event listeners.

// ./main/main.ts

disconnectedCallback() {
  super.disconnectedCallback();
  this.$emit('store-disconnected');
}

Using store fragments

To use a store fragment in your custom element simply inject it using its interface as the DI key.

export type EventMap = TradeEntryEventDetailMap;

export class TradeEntryForm extends EventEmitter<EventMap>(FASTElement) {
  @TradeEntry tradeEntry: TradeEntry;
}

Now in your template you can use the values from the store fragment and raise typed events.

import type {TradeEntryForm, EventMap} from './home';

const inputEmit = createInputEmitter<EventMap>();

<zero-text-field
  type="number"
  :value=${x => x.tradeEntry.price}
  @change="${inputEmit('trade-entry-price-changed')}"
>

createEmitChangeAs is a utility from @genesislcap/foundation-events to allow components with string change inputs to emit their string values as typed event detail payloads. It will warn you if you try to use it with a component that doesn't have a string target.value type. It's a convenience method only and is the same as writing:

<zero-text-field
  type="number"
  :value=${x => x.tradeEntry.price}
  @change="${(x, c) => x.$emit('trade-entry-price-changed', targetValue(c))}"
>

We will be adding a number of these for Selects and other primitives. You can of course call to a class method and have that emit the typed event to the store, but the idea with the helpers are to remove some boilerplate and misdirection.

<zero-select @change=${((x, c) => x.instrumentIdChange(c.event.target as Select))}>
instrumentIdChange(target: Select) {
  this.$emit('trade-entry-instrumentID-changed', target.selectedOptions[0]?.value);
}

If the component in question is perhaps a library asset or simply needs to remain unaware of the DI, you can map store properties to the custom element via its attributes.

// ./some-parent/some-parent.template.ts

<trade-entry-form price="${x => x.tradeEntry.price}" />

If the components are specific to your application and won't be shared, and you've split up your store into appropriate domain specific fragments, injecting the store fragment directly into where it's needed will greatly reduce mapping boilerplate. Remember components should only have access to the data they need. If the injected store fragment provides them more than that, consider splitting that store fragment up further, or reworking your store structure.

Accessing values from one store fragment to another

If you need to read values from another part of the store in your store you may be able to simple inject it, but you won't need to pass it to the super class for processing if that's done elsewhere. If you would rather not inject it, you can use this.root.fragmentX.fragmentY.value. You should provide root store type information when creating your store fragment that will be reading root, for example:

import type {Store as StoreRoot} from '../store';
...
class DefaultPositions extends AbstractStore<Positions, PositionsEventDetailMap, InternalEventDetailMap, StoreRoot> implements Positions {
  onPositionSelected = this.createListener<PositionEntity>('position-selected', (position) => {
    const lastTradeId = this.root.trades.selectedTrade.id;
    ...
  });
}

Testing with the injected dependency in place

Using a DI based store is very powerful as it allows us to swap out our store fragments when needed, ie. a unit test:

const mock = new TradeEntryMock();
const Suite = createComponentSuite<TradeEntryForm>('TradeEntryForm Test', () => tradeEntryForm(), null, [
  Registration.instance(TradeEntry, mock),
]);

The code in your component remains as is.

Committing value mutations

Stores are read only, so if you try to set a property directly TS will flag this. To commit a value to the store you must emit a known store event. In these event handlers, you call this.commit prepended with the value you want to mutate. For example:

// some trades store fragment

onTradeSelected = this.createListener<TradeEntity>('trade-selected', trade => this.commit.selectedTrade = trade);

The this.commit interface is typed the same as the store fragment itself for full code completion, and simply acts as a proxy to the underlying store. Currently, an alternative api also exists called commitValue if you prefer.

onTradeSelected = this.createListener<TradeEntity>('trade-selected', trade => this.commitValue('selectedTrade', trade));

The store won't complain if you forget or just don't want to use the commit api, it just means value changes won't be tracked overtime.

onTradeSelected = this.createListener<TradeEntity>('trade-selected', trade => this.selectedTrade = trade);

Side effects

When you need to reach out to another part of the system or generally do some async work, you should ensure the initial event handler is async. We recommend that you don't commit values in this handler, as it may become difficult to track mutations overtime if other events are occurring, but it's really up to you if you want to just await and commit. Ideally we want store interactions to be standardised, predictable and traceable.

constructor(
  @TradeEntry readonly tradeEntry: TradeEntry,
  @TradesService readonly service: TradesService,
) {
  super(tradeEntry); // < only pass the super child sub store fragments

  /**
   * Listeners not on the public interface can be created anonymously if you prefer
   */
  this.createListener<TradeEntity[]>('trades-load-success', trades => this.commit.trades = trades);
  this.createErrorListener('trades-load-error', () => this.commit.trades = undefined);
}

onTradesLoad = this.createAsyncListener<string>('trades-load', async (positionId) =>
  this.invokeAsyncAPI(
    async () => this.service.getTrades(positionId),
    'trades-load-error',
    'trades-load-success'
  )
);

Errors

When you use this.createErrorListener, stores will automatically keep track of any errors that may occur in a store.errors map that is keyed by the event type. This means your store can hold multiple errors at a time. To output all the messages at once you can bind to store.errors.messages, for example:

<pre>${x => x.trades.errors.messages}</pre>

You can check for specific errors too:

${when(x => x.trades.errors.has('trade-insert-error'), html<TradeInsertErrorFeedback>`...`)}

This granularity can be useful if say you wanted to raise error notifications and allow users to clear errors one-by-one.

this.trades.errors.delete('trade-insert-error');

// or clear them all
this.trades.errors.clear();

Please note that store.errors is not a true Map so doesn't have all the native Map apis, however it should have most of what you need. You also operate on the errors map directly without raising events. This is like this for simplicity, and because errors are transient in nature.

Store bindings

You can easily bind to the properties and getters of a store inside the internal template engine, or outside of the template engine via the store's binding() api.

Template store bindings

For example, you may wish to monitor the value of this.store.ready via template bindings to conditional render a loading phase in your template.

// ./main/main.template.ts

${when(x => !x.store.ready, html<Loading>`Loading...`)}

...or monitor the value indirectly, to swap out the entire template.

// ./main/main.ts

selectTemplate() {
  return this.store.ready ? MainTemplate : LoadingTemplate;
}

// ./main/main.template.ts

<template ...>
  ${x => x.selectTemplate()}
</template>

Direct store bindings

To bind to the store outside the template engine, you use the store's binding() api. The store's binding() api is strongly typed, so you will be unable to bind to a non-existent property or getter. You also don't need to think about if the data point is a property or a getter, perhaps returning derived data, as the api works the same for both cases.

Some examples:

// TS knows the returned type of value
this.store.binding(x => x.ready, value => {...});
this.store.binding(x => x.someNumericProp, value => {...});

// TS needs some help to type the returned value, so you need to provide that (we hope to fix this).
this.store.binding<boolean>('ready', value => {...});
this.store.binding<number>('someNumericProp', value => {...});

// You can even create your own derived binding that are not already defined as getters in the store
this.store.binding(x => x.prop1 * x.prop2, value => {...});

// These can even be volatile providing you pass true for isVolatileBinding
this.store.binding(x => x.someToggle ? x.prop1 : x.prop2, value => {...}, true);

Here is an example of using the underlying bindingObserver as per docs.

this.store.binding(x => x.prop1).subscribe({
  handleChange(source) { // < note that the source is the bindingObserver itself, x => x.prop1, and not the value of the change
    ...
  }
})

Updates are batch processed, so if a bunch of properties are updated in the store that any of the stores getters or derived bindings you've passed in via the binding() api use, they will by design tick only once to avoid any unnecessary updates and potential re-renders. See Reactivity for more information.

Direct store bindings as RxJS

Stores also offer a bindingAsRx() api which returns a Rxjs Observable to allow you to observe a value using Rxjs which may be useful depending on the needs of your application.

const entireStore$ = this.store.bindingAsRx().subscribe(value => {...});

const ready$ = this.store.bindingAsRx('ready').subscribe(value => {...});

const prop1$ = this.store.bindingAsRx(x => x.prop1).subscribe(value => {...});

We may need to do some additional work here to finish this feature.

UI state in stores

You may want to keep your UI state in your stores alongside the application data. If so consider splitting these up into separate fragments, for example:

  • TradeEntry: Trade entry field values (Data).
  • TradeEntryUI: Trade entry presentation status, isOpen, isLoading etc. (UI State).

UI state can of course be kept in the component itself, however there may be reasons to keep it in the store. Consider if you needed to know if the trade entry was being presented on-screen in a different part of the application. You could try to listen for the 'trade-entry-ui-open' event, but until the event hits the target store and is committed, the true state isn't guaranteed. The store should be the single source of truth.

With that in mind, we could inject that UI state store fragment and bind to the value of interest, for example:

// some other part of the UI

this.tradeEntryUI.binding(x => x.isOpen, value => value ? this.stop() : this.start());

Keeping UI state in a store also might make it easier for you to save a snapshot of the UI state for rehydration, ie. what windows did the user have open etc. You may wish to add some middleware to the commit proxy (base store concept) for converting state transitions into browser history entries, which might allow you to deep link and press browser back button to say close the modal, with each back button moving backwards though the UI state. Be careful not to blur the lines between the data and the UI state in our store fragments.

For example a UI conditional could map to a UI sub fragment, whereas a datapoint could map to the parent fragment. The component would only need tradeEntry injected to have access to both, depending on how we structure fragments.

${when(x => x.tradeEntry.ui.isOpen, html<TradeEntryForm>`
    <zero-card>
        <zero-text-field :value=${x => x.tradeEntry.quantity}>
    </zero-card>
`}

Installation

To enable this module in your application, follow the steps below.

  1. Add @genesislcap/foundation-store as a dependency in your package.json file. Whenever you change the dependencies of your project, ensure you run the $ npm run bootstrap command again. You can find more information in the package.json basics page.
{
  ...
  "dependencies": {
    ...
    "@genesislcap/foundation-store": "latest"
    ...
  },
  ...
}

License

Note: this project provides front-end dependencies and uses licensed components listed in the next section; thus, licenses for those components are required during development. Contact Genesis Global for more details.

Licensed components

Genesis low-code platform

Readme

Keywords

none

Package Sidebar

Install

npm i @genesislcap/foundation-store

Weekly Downloads

918

Version

14.173.2

License

SEE LICENSE IN license.txt

Unpacked Size

345 kB

Total Files

105

Last publish

Collaborators

  • genesisnpm