@diffx/angular
TypeScript icon, indicating that this package has built-in type declarations

3.0.1 • Public • Published

@diffx/angular

Diffx is a state management library that focuses on three things:

  • Make it easy to learn and use
  • Get rid of boilerplate
  • Make great devtools

Key features

🤏 Small API and a very compact syntax
🔍 Tracks the reason behind changes to the state
🔧 Devtools that track:
     - what, when, where and why state changed
     - async start/resolution
     - nested changes
     - changes triggered by watchers
💾 Built in persistence
📝 Written in Typescript, inferring your types

Supported frameworks

react logo React --> @diffx/react
vue logo Vue.js --> @diffx/vue
svelte logo Svelte --> @diffx/svelte
angular logo Angular --> @diffx/angular
rxjs logo RxJS --> @diffx/rxjs
No framework --> @diffx/core

Installation

npm install @diffx/angular

And install the devtools browser extension for a better development experience (view documentation).

Setup

Fix angular change detection and the async pipe

Angular has the concept of code running inside zones, and anything running outside a zone will not trigger change detection.

To ensure observables returned from Diffx are run in the correct zone, import zone-patch-rxjs in your polyfills.ts file after your import of zone.

import 'zone.js/dist/zone';
import 'zone.js/dist/zone-patch-rxjs'; // <--- This thing right here

Usage

Configure Diffx

setDiffxOptions(options) is used to configure which global features to enable for Diffx. Should be run before any code interacts with Diffx.

  • options - an options object that configures how Diffx works internally
import { setDiffxOptions } from '@diffx/angular';

setDiffxOptions({ devtools: true });
Show all options
import { setDiffxOptions } from '@diffx/angular';

setDiffxOptions({
    /**
     * Enable viewing the state history in devtools.
     * Not recommended for use in a production environment.
     * If set to true, `createDiffs` will also be implicitly true.
     *
     * Default: false
     */
    devtools: false,
    /**
     * Store a stack-trace with every diff if `createDiffs` is enabled.
     * Will be displayed in devtools to help with tracking down
     * which code is making state changes.
     *
     * NOT recommended in production environment since creating stack traces is a slow operation!
     *
     * Default: false
     */
    includeStackTrace: false,
    /**
     * Persist the latest snapshot of all states and automatically use that as the initial state
     *
     * Default: false
     */
    persistent: false,
    /**
     * Location for storing persistent state.
     * E.g. localStorage or sessionStorage
     *
     * Default: null
     */
    persistenceLocation: null,
    /**
     * Whether to record all diffs of the state in-memory.
     *
     * Default: false
     **/
    createDiffs: false,
    /**
     * Max nesting depth.
     *
     * If a loop of setState <--> watchState is accidentally created, it will run off and crash
     * (and potentially crash the main thread). To avoid this, a max nesting depth can be set.
     *
     * Default: 100
     */
    maxNestingDepth: 100
});

Create state

createState(namespace, state) is used to create state in Diffx.

  • namespace - a string which is used as the key when storing the state in the state tree. The namespace must be unique.
  • state - an object which contains the initial state
import { createState } from '@diffx/angular';

export const usersState = createState('users state', { names: [] });
export const clickCounter = createState('click counter', { count: 0 });

console.log(clickCounter.count); // --> 0

You can create as many states as you like and access them as regular objects to read their values.

Configure persistence

createState(..., ..., options)

  • options- optional settings for this particular state
    • persistent - Persist the latest snapshot of this state and automatically use that as the initial state. Setting it to false will exclude the state from persistence, even though it is globally set to true in setDiffxOptions.
      Default: false

    • persistenceLocation - Location for persisting this particular state - e.g. window.sessionStorage.
      Default: false

import { setDiffxOptions, createState } from '@diffx/angular';

// this enables persistence for all states globally
setDiffxOptions({
    persistent: true,
    persistenceLocation: sessionStorage
})

// this disables persistence for a specific state (if it's enabled globally)
export const clickCounter = createState('click counter', { count: 0 }, { persistent: false });

// this state is persisted in accordance with the global settings in `setDiffxOptions`
export const clickCounter = createState('click counter', { count: 0 });

// this state is persisted in localStorage instead of the globally defined persistenceLocation
export const clickCounter = createState('click counter', { count: 0 }, { persistenceLocation: localStorage });

Update state

setState(reason, mutatorFunc) is used to wrap changes to the state.

  • reason - a string which explains why the state was changed. Will be displayed in the devtools extension for easier debugging.
  • mutatorFunc - a function that wraps all changes to the state.
import { setState } from '@diffx/angular';
import { clickCounter } from './createState-example';

setState('increment the counter', () => clickCounter.count++);
Synchronous usage

Since Diffx is proxy-based, it will keep track of anything happening within setState().
Multiple states can be changed within one setState():

import { setState } from '@diffx/angular';
import { clickCounter, usersState } from './createState-example';

setState('Change the counter and add a user', () => {
    clickCounter.count++;
    if (clickCounter.count > 2) {
        clickCounter.count = 200;
    }
    usersState.names.push('John');
})

This will also create an entry in the devtools
devtools entry screenshot

Asynchronous usage

setState(reason, asyncMutatorFunc, onDone [, onError]) is used to make asynchronous changes to the state (and enhances tracking of async state in Diffx devtools).

  • reason - a string which explains why the state was changed. Will be displayed in the devtools extension for easier debugging.
  • asyncMutatorFunc - a function that is free to change the state, and returns a rxjs Observable.
  • onDone - a function that receives the result of asyncMutatorFunc as an argument, and is free to change the state.
  • onError - a function that receives the error from asyncMutatorFunc as an argument, and is free to change the state.
import { createState, setState } from '@diffx/angular';
import { fetchUsersFromServer } from './some-file';

export const usersState = createState('users state', {
    isFetching: false,
    names: [],
    fetchErrorMessage: ''
});

setState(
    'fetch and update usersState',
    () => {
        // set state before the async work begins
        usersState.fetchErrorMessage = '';
        usersState.names = [];
        usersState.isFetching = true;
        // return the async work
        return fetchUsersFromServer();
    },
    result => {
        // the async work succeeded
        usersState.names = result;
        usersState.isFetching = false;
    },
    error => {
        // the async work failed
        usersState.fetchErrorMessage = error.message;
        usersState.isFetching = false;
    }
);

The asyncMutatorFunc and its resolution with onDone or onError will be tracked in the devtools:

onDone

async onDone in devtools

onError

async onError in devtools

Nesting setState() inside setState()

To avoid repeating yourself, it can be beneficial to wrap setState in a function that can be reused.

import { createState, setState } from '@diffx/angular';
import { usersState } from './createState-example';

export function addUser(name) {
    setState('Add user', () => usersState.names.push(name));
}

To make the state history more readable, the usage of the wrapped setState above can be used inside a setState providing a reason for the changes and grouping them.

// in some other file
import { setState } from '@diffx/angular';
import { addUser } from './example-above';

setState('PeopleComponent: User clicked "Save usersState"', () => {
    addUser('John');
    addUser('Jenny');
});

This nesting will be displayed in the devtools as an indented hierarchical list, clarifying why "Add user" happened:
nesting in devtools

Nesting can go as many levels deep as desired, making it easy to see who did what and why, and at the same time making it easy to discover reusable compositions of setState.

Why can't I directly modify the state?

By having the freedom to change state from anywhere in the codebase, state can quickly get out of control and be difficult to debug if there is no human-readable reasoning behind why a change was made.
To ensure that the usage experience stays developer friendly, easy to debug, and help with identifying which code needs refactoring, Diffx enforces the use of setState since it groups changes and allows the developer to specify a reason for the changes.

Any changes made to the state outside of setState() will throw an error.

import { clickCounter } from './createState-example';

clickCounter.count++; // this will throw an error

Observe state

observeState(stateGetter) is used for creating an observable of the state.

  • stateGetter - a function which returns the state(s) to be observed
import { observeState } from '@diffx/angular';
import { clickCounter } from './createState-example';

observeState(() => clickCounter.count); // --> observable
Using setState() inside observeState()
import { observeState, setState } from '@diffx/angular';
import { clickCounter, usersState } from './createState-example';

observeState(() => clickCounter.count)
    .pipe(
    	filter(count => count === 5),
        take(1)
    )
    .subscribe(countIsFive => {
        if (!countIsFive) return;
        setState('counter has the value 5, so I added another user', () => {
            usersState.names.push('Jenny');
        });
    });

This will also be tracked in the devtools and tagged with "watcher".
devtools watcher example

The tag can be hovered/clicked for more information about its trigger origin.
devtools watcher hover example

Observing projections
import { observeState } from '@diffx/angular';
import { clickCounter } from './createState-example';

observeState(() => clickCounter.count > 5)
  .subscribe(isGreaterThanFive => {
  	console.log(isGreaterThanFive); // --> true/false
  });
Observing multiple states
import { observeState } from '@diffx/angular';
import { clickCounter, usersState } from './createState-example';

observeState(() => [clickCounter.count, usersState.names])
  .subscribe(([count, names]) => {
  	console.log(count) // --> number
  });
Controlling how state is observed

To have fine-grained control over how the state is observed, an options object can be provided as the second argument.

import { observeState } from '@diffx/rxjs';
import { clickCounter } from './createState-example-above';

const observable = observeState(() => clickCounter.count, {
    /**
     * Whether to start with emitting the current value of the observed item(s).
     *
     * Default: `false`
     */
    emitInitialValue: false,
    /**
     * Whether to emit each change to the state during .setState (eachValueUpdate),
     * the current state after each .setState and .setState nested within it (eachSetState),
     * or to only emit the final state after the outer .setState function has finished running (setStateDone).
     *
     * Default: `setStateDone`
     */
    emitOn: 'eachSetState' | 'setStateDone' | 'eachValueUpdate',
    /**
     * Custom comparer function to decide if the state has changed.
     * Receives newValue and oldValue as arguments and should return `true` for changed
     * and `false` for no change.
     *
     * Default: Diffx built in comparer
     */
    hasChangedComparer: (newValue, oldValue) => 'true / false'
});

Destroy state

destroyState(namespace) is used for removing state from diffx.

  • namespace - the namespace (string) to destroy

Any watchers of the destroyed state will not be automatically unwatched.

import { destroyState } from '@diffx/angular';

destroyState('click counter');

Devtools browser extension

Installation

The extension can be installed through the Chrome web store.

Features

Diffx devtools is made to give insights into

  • Why state was changed
  • Which state was changed
  • When did it change
  • What caused the change
Overview

It will show up as a tab in the browser devtools when it detects that the page is using Diffx and debugging has been enabled (see setDiffxOptions).

Devtools location

The left pane displays a list of changes (diffs) to the state along with their reason.
The right pane displays the Diff, State and Stacktrace (if stacktrace has been enabled in setDiffxOptions).

Diff tab

Displays the difference between each change made by setState().

Diff tab preview

State tab

Displays the current state at the selected diff.

State tab preview

Stacktrace tab

Displays the stack trace for the code that led to this state change.

Stacktrace tab preview

State namespace indicators

The dots in the left tab indicate which state was changed with their color, can be hovered to view the namespace and clicked to filter the list by that state.

State type hints

Visualizing nested setState

For places where setState() has been used inside setState(), the left pane will display a nested view with colors used for displaying nesting depth.

Nested setState preview

Tracing async setState

For async operations done with setState(), the left pane will display an async tag where the operation starts, and a resolve/reject tag where the async operation finished.
These tags are highlighted with a color to make it easier to spot which operations belong together and are also clickable to filter by.

setState preview

Tracing state changed in watchState

If a watchState() runs setState(), the left pane will display a watcher tag to indicate that the change was triggered.

watchState tracing preview 1

The watcher tag can be hovered to see which state change triggered it and clicked to find the state change.

watchState tracing preview 2

To see where in the code the watcher was run, enable includeStackTrace in setDiffxOptions and open the Stacktrace tab for the entry tagged with the watcher.

Highlight/filter changes to a specific value

The Highlight and Filter button can be used to find the state changes that affected a specific value.

highlight/filter preview

Do I need a state management library?

A lot of projects start out with keeping state localized. When the project grows and requirements change, some of that state usually gets refactored to become shared state. That might work well for a while, but as the project grows even further, it can become a real mental burden to keep track of the current state and how it came to be. The author of the code might not feel this way, but the next developer to join the project is almost guaranteed to have a hard time keeping up with it. This is usually when developers will reach for a library to aid with state management.

If you foresee a project that will grow in size over time, and/or other developers will join, it might be a good idea to use a well documented and inspectable way to manage state.

Why Diffx?

There are a lot of great state management libraries out there.

  • Some focus on a rigid structure, suitable for large teams that want predictable code patterns, often at the cost of writing a lot of boilerplate.
  • Some provide the same ease of use as local state, often at the cost of having less context which might make it more difficult to debug.

Diffx tries to be the best of both worlds by

  • making it easy to provide context/intent behind any changes, which in turn makes it easy to reason about how a specific state came to be. It makes the state self-documenting.
  • compactness comparable to local state
  • offloading the responsibility to stay in control over to the library/devtools

There are a heap of great choices out there, and the library you end up using will probably stay in your project for a long time. Diffx is a tool - I recommend you to look into several of the popular ones before you decide which is the best fit for your project.

Credits and thanks

  • Thanks to the team behind Vue.js for making a great framework and the @vue/reactive package this project depends on.
  • Thanks to Benjamine, the creator of jsondiffpatch which this project uses for creating diffs.
  • Thanks to all developers teaming together to share their creations with others

Package Sidebar

Install

npm i @diffx/angular

Weekly Downloads

9

Version

3.0.1

License

MIT

Unpacked Size

22.3 kB

Total Files

5

Last publish

Collaborators

  • jbjorge