@domx/statechange
TypeScript icon, indicating that this package has built-in type declarations

1.0.0 • Public • Published

StateChange · GitHub license Build Status Lines npm

StateChange is a monad-like object that enables changing a property on an HTMLElement in a functional way.

Installation
Basic usage
Configuration
Middleware (logging, error handling, immutable state handling)
Full example
Advanced usage

Installation

npm install @domx/statechange

Basic usage

The basic examples will be using this default state...

const defaultState = {
    isLoading: false,
    userType: null,
    users: []
};

and this HTMLElement...

class UserListElement extends HTMLElement {
    state = defaultState
}
window.customElements.define("user-list", UserListElement);

Changing state

Creating a StateChange object

A StateChange object is created by passing it the HTMLElement containing the state property you want to manage.

StateChange.of(el)
// or
new StateChange(el)

State functions

A state function is a function that takes a state object and returns a new state object.

const setIsLoading = state => ({
    ...state,
    isLoading: true
});

Setting the next state

Changing the state can be done by calling next on the StateChange instance.

class UserListElement extends HTMLElement {
    //...
    setIsLoading() {
        StateChange.of(this)
            .next(setIsLoading); // sets the next state
    }
}

Notifying the change

Then dispatch a state-change event by calling dispatch on the StateChange instance.

class UserListElement extends HTMLElement {
    //...
    setIsLoading() {
        StateChange.of(this)
            .next(setIsLoading);
            .dispatch(); // dispatches the 'state-change' event
    }
}

Async or branching functions

Using tap

When needing to do more than just setting the next state object, a tap function can be used to perform any logic, branching, or asynchronous operations.

import { EventMap } from "@domx/eventmap";
import { event } from "@domx/eventmap/decorators";

class UserListElement extends EventMap(HTMLElement){
    //...
    @event("request-users")
    requestUsers() {
        StateChange.of(this)
            .tap(requestUsers);
    }
}

When using tap, the argument passed is the StateChange instance.

const requestUsers = async stateChange => {
  const response = await sendRequest({
    url: "/api/users"
  });

  // now that we have the users, use the stateChange `next`
  // function to set those users
  stateChange
    .next(receiveUsers(response.users))
    .dispatch();
};

Passing an argument to a state function

The receiveUsers function takes the list of users then needs to return a function that can be passed to the next method.

const receiveUsers = users =>
    function recieveUsers(state) {
        return {
            ...state,
            users
        };
    }

The reason for writing the method with an inner function is so that logging can pick up on the function name. The method could be written like this:

const receiveUsers = users => state => ({
    ...state,
    users
});

But logging would log an anonymous method.

Dispatching events

In addition to using the dispatch method which dispatches the state-change event, any event can be dispatched using the dispatchEvent method.

const requestUsers = stateChange => {
  ///...
  stateChange
    .next(receiveUsers(response.users))
    .dispatch()
    .dispatchEvent(new CustomEvent("show-system-toast", {
        detail: {text: "Users loaded."}
    }));
};

Configuration

The StateChange constructor takes an optional configuration object that allows you to control the name of the state property and the name of the state-change event.

// The default configuration:
StateChange.of(this, {
    property: "state",
    changeEvent: "state-changed"
});

// Changing the property and change event name
StateChange.of(this, {
    property: "currentUser",
    changeEvent: "current-user-changed"
});

A string can also be used to set the property and change event.

StateChange.of(this, "user");
// sets the property to "user"
// and sets the changeEvent to "user-changed"

For use with DataElements, the change event name will first be looked for on a static dataProperties property.

export class TestStateProp3 extends HTMLElement {
  static dataProperties = {
    user: {
      changeEvent: "user-change-event"
    }
  }

  user = {
    userName: "joeuser"
  };

  changeName(userName: string) {
    // dispatch here will trigger the "user-change-event"
    // as described in the static dataProperties property
    StateChange.of(this, "user")
      .dispatch();
  }
}

Middleware

StateChange exposes middleware to hook into both the next and the tap functions.

There are also four functions available to apply logging, error handling, and immutable state changes via Immer.

Redux Dev Tool Logging

Logs next and tap calls to the Redux dev tools extension.

import {applyStateChangeRdtLogging} from "@domx/StateChange/applyStateChangeRdtLogging";
applyStateChangeRdtLogging();

Immer - to simplify handling immutable data structures

See https://immerjs.github.io/immer/produce

import {applyImmerToStateChange} from "@domx/StateChange/applyImmerToStateChange";
applyImmerToStateChange();

Logging

Logs next and tap calls with state snapshots.

import {applyStateChangeConsoleLogging} from "@domx/StateChange/applyStateChangeConsoleLogging";
applyStateChangeConsoleLogging();

// or call with collapsed:true to collapse console logging groups
applyStateChangeConsoleLogging({collapsed:true});

Error handling

Logs and throws the error.

import {applyStateChangeErrorHandling} from "@domx/StateChange/applyStateChangeErrorHandling";
applyStateChangeErrorHandling();

Adding custom middleware

import {StateChange} from "@domx/StateChange";

StateChange.applyNextMiddleware(stateChange => next => state =>{
    // add custom behaviors and return next
    return next(state);
});

StateChange.applyTapMiddleware(next => stateChange => {
    // add custom behaviors and call next
    next(stateChange);
});

// removes all middelware methods
StateChange.clearMiddleware();

Full example

This is a full example using the basic methods for changing state.

import { StateChange } from '@domx/statechange';
import { EventMap, event } from '@domx/eventmap';
import { showSystemToastEvent } from '../../system-toast/events';
export { UserListElement };


const defaultState = {
    isLoading: false,
    userType: null,
    users: []
};

class FetchUsersEvent extends Event {
    static eventType = "fetch-users";
    userType:string;
    constructor(userType:string) {
        super(FetchUsersEvent.eventType);
        this.userType = userType;
    }
}

class UserListElement extends EventMap(HTMLElement) {

    // set the default state
    state = defaultState;

    // a UI event that indicates users of a specific
    // userType should be fetched
    @event(FetchUsersEvent.eventType)
    fetchUsers(event: FetchUsersEvent) {
        // use StateChange to set the userType
        // and request the users
        StateChange.of(this)
            .next(setUserType(event.userType))
            .tap(requestUsers)
            .dispatch();
    }
}


// example of function receiving input (userType)
// and returning a state function
const setUserType = userType =>
    function setUserType(state) {
        return {
            ...state,
            userType
        };
    };


// behavioral function used to make an HTTP request then
// update the state when the users are received
const requestUsers = stateChange => {    
    const {userType} = stateChange.getState();
    
    stateChange
        .next(setIsLoading)
        .dispatch();

    const response = await sendRequest({
        url: "/api/users",
        params: { userType }
    });

    // now that we have the users, use stateChange
    // to set the next state and dispatch the events
    stateChange
        .next(receiveUsers(response.users))
        .dispatch()
        .dispatchEvent(showSystemToastEvent({text: "Users loaded."}));
};


// simple state function
const setIsLoading = state => ({
    ...state,
    isLoading: true
});


// another function receiving input and
// returning a state function
const receiveUsers = users =>
    function receiveUsers(state) {
        return {
            ...state,
            isLoading: false,
            users
        };
    };

Advanced usage

The StateChange object enables many ways of composing functionality and functional programming encourages breaking down functions into their smallest operations.

Breaking down functions

If a function is large or has if/else statements it is likely a candidate for being broken down.

Breaking down functions has the added benifits of being re-usable, easy to test (or no test needed at all), and tends to be more readable.

Conditionals

Given this simple conditional

const setNextSkip = state => {
    if (state.totalCount < state.take + state.skip) {
        return {
            ...state
            skip: state.skip + 50;
        }
    }
    return state;
}

It can be broken down into 3 separate functions.

const hasMoreItems = state =>
    state.totalCount < state.take + state.skip;

const skipMoreItems = state => ({
    ...state,
    skip: state.skip + 50;
});

const setNextSkip = state =>
    hasMoreItems(state) ? skipMoreItems(state) : state;

Using pipes

Using a pipe or a compose method can help when needing to incorporate functions outside of StateChange.

The following method is not too bad but is a candidate for pipes.

const setFilterFromUrl = stateChange => {
    const { searchParams } = new URL(window.location.href);
    const filter = searchParams.get("filter");
    stateChange
        .next(setAccountFilter(filter));
        .dispatch();
};

First by turning the first two lines into functions

const getSearchParams => new URL(window.location.href).searchParams;
const getFilterParam = searchParams => searchParams.get("filter");

In order to use StateChange with a pipe there needs to be a way to insert the stateChange parameter into the pipe. This can be done with StateChange.nextWith(stateChange).

const setFilterFromUrl = stateChange => pipe(
    getSearchParams,
    getFilterParam,
    setAccountFilter(filter),
    StateChange.nextWith(stateChange)
    StateChange.dispatch
)();

There are a series of static StateChange methods that enable function composition.

  • StateChange.nextWith(stateChange)(fn) - A lifting function that calls next
  • StateChange.tapWith(stateChange)(fn) - A lifting function that calls tap
  • StateChange.dispatch(stateChange) - A chainable call to dispatch
  • StateChange.dispatchEvent(stateChange) - A chainable call to dispatchEvent
  • StateChange.next(fn)(stateChange) - A chainable call to next
  • StateChange.tap(fn)(stateChange) - A chainable call to tap
  • StateChange.getState(stateChange) - Returns the current state

Async pipe example

The requestUsers method from the example above could be written using an async pipe.

Here is the method for reference:

const requestUsers = stateChange => {    
    const {userType} = stateChange.getState();    
    stateChange
        .next(setIsLoading)
        .dispatch();
    const response = await sendRequest({
        url: "/api/users",
        params: { userType }
    });
    stateChange
        .next(receiveUsers(response.users))
        .dispatch()
        .dispatchEvent(showSystemToastEvent({text: "Users loaded."}));
};

First we can extract two more methods. One to get the userType

const getUserType = state => state.userType;

And another to do the call to sendRequest

const sendUsersRequest = async userType => await sendRequest({
    url: "/api/users",
    params: { userType }
});

Now the requestUsers method could be written using pipeAsync

const requestUsers = stateChange => pipeAsync(
    StateChange.next(setIsLoading), // 2
    StateChange.dispatch, // 3
    StateChange.getState, // 4
    getUserType, // 5
    sendUsersRequest, // 6
    recieveUsers, // 7
    StateChange.nextWith(stateChange), // 8
    StateChange.dispatch // 9
    StateChange.dispatchEvent(AiSystemFeedback.systemToastEvent({text: "Users loaded."})) // 10
)(stateChange); // 1

// 1 - Pass the stateChange instance to the argument of the first function in the pipe
// 2 - Get the next state from `setIsLoading`
// 3 - Call dispatch
// 4 - The `getUserType` method requires the `state` object
// 5 - Pass the user type to `sendUsersRequest`
// 6 - This is the async/promise that will return the `response` to the `recieveUsers` method
// 7 - This returns a state function (recieves and returns state)
// 8 - Taps back into the `stateChange` to call next with the function recieved in #7
// 9 - Dispatch the change event
// 10 - Dispatch some other event

Functions, Functors, and Monads (oh my)

Useful Monad-like Types

Since StateChange is composable it opens the door for additional functional patterns. Here are a few other popular Monad like types that may be useful.

  • Array - used for lists
  • Promise - used to encapsulate possible async operations
  • Either (Left/Right) - control flow most commonly used with error handling
  • Result (Ok/Error) - another implementation of Either.
  • Maybe (Something/Nothing) - helps with null calls.

Optional chaining and mullish coalescing can also help:
https://github.com/tc39/proposal-optional-chaining
https://github.com/tc39/proposal-nullish-coalescing

Other references

Package Sidebar

Install

npm i @domx/statechange

Weekly Downloads

7

Version

1.0.0

License

MIT

Unpacked Size

248 kB

Total Files

53

Last publish

Collaborators

  • jhorback