lit-element-state-decoupler

A lightweight utility for state handling outside of the component for litelements

Usage no npm install needed!

<script type="module">
  import litElementStateDecoupler from 'https://cdn.skypack.dev/lit-element-state-decoupler';
</script>

README

lit-element-state-decoupler

Version Size vulnerabilities dependencies code quality Statements Branch Functions Lines

A lightweight utility for state handling outside of the component for lit-elements

Install

npm install lit-element-state-decoupler

Usage

You have three methods to choose from, useState, useReducer and useWorkflow.

The state is a simple way to create a stateful object, the reducer allows you to create a handler for multiple actions, workflow allows you to create more complex activities.

Note that you need a lit-element version that comes with the requestUpdate() function for this library to work.

Example

See the following page for two simple todo-list examples using the useState and the useReducer functions:

https://matthiaskainer.github.io/lit-element-state-decoupler/

State

Getting access to the state can be done by calling the useState function.

This should be done on one location in the lifecycle, and not inside a loop with a changing number of iterations because it tries to re-resolve the correct element from the previous run.

render() {
    const {get, set, value, subscribe} = useState<YourState>(this, defaultState, options)
}

Depending on your preferences you can either use the get & set functions, or the state property.

const first = useState(element, defaultState);
const { value: second } = useState(element, defaultState);
return html`
    <button @click=${() => first.set(first.get() + 1)}>
        First State: ${first.get()} - click on me to increment
    </button>
    <button @click=${() => (second = second + 1)}>
        Second State: ${second} - click on me to increment
    </button>
`;

The behaviour is the same, and can even be mixed

const { get, set, value } = useState(element, defaultState);
return html`
    Variant State: ${value}
    <button @click=${() => set(value + 1)}>
         click on me to increment
    </button>
    <button @click=${() => (value = get() + 1)}>
        Or click on me to increment
    </button>
`;

Options

variable description
updateDefauls: boolean (default: false) If set to true, updates the state with the default values every time it is called

The state exposes three functions, get, set and subscribe, and takes in a reference to the current LitElement and a default state. Whenever the state is updated, the LitElement will be updated, and the render() method of the component will be called.

render() {
    const {get, set} = useState<StateExample>(this, { values: [] })
    return html`
        <button @click="${() => set([...get(), "lala"])}">Add value</button>
        <textarea>${get().values.join(",")}</textarea>
    `
}
function description
get() => YourState Returns the current state
set(newState: YourState) => void Updates the state to a new state
subscribe(yourSubscriberFunction) => void Notifies subscribed functions if the state has been changed

Reducer

Getting access to the reducer can be done by calling the useReducer function.

This should be done on one location in the lifecycle, and not inside a loop with a changing number of iterations because it tries to re-resolve the correct element from the previous run.

render() {
    const {get, set, subscribe} = useReducer<YourState>(this, yourReducer, defaultState, options?)
}

Similar to the state, the reducer exposes three functions, get, set and subscribe, and takes in a reference to the current LitElement and a default state. In addition, it also requires a reducer function and can directly trigger custom events that bubble up and can be used by the parent.

Whenever the state is updated, the LitElement will be updated, and the render() method of the component will be called.

Reducer Function

The reducer follows a definition of (state: T, payload: unknown) => {[action: string]: () => T}, so it's a function that returns a map of actions that are triggered by a specific action. Other then in redux, no default action has to be provided. If the action does not exist, it falls back to returning the current state.

An example implementation of a reducer is thus:

class StateExample {
  constructor(public values = []) {}
}

const exampleReducer = (state: StateExample) => ({
  add: (payload: string) => ({ ...state, value: [...state.values, payload] }),
  empty: () => ({ ...state, value: [] }),
});

Options

variable description
dispatchEvent: boolean (default: false) If set to true, dispatches a action as custom event from the component
updateDefauls: boolean (default: false) If set to true, updates the state with the default values every time it is called

set

The reducer can be triggered whenever the reducer's set function is triggered, i.e.

render() {
    const {set, get} = useReducer<StateExample>(this, exampleReducer, { values: [] });
    return html`
        <button @click="${() => set("add", "lala")}">Add value</button>
        <button @click="${() => set("empty")}">Clean</button>
        <textarea>${get().values.join(",")}</textarea>
    `
}

set with custom events

If specified in the options, the set will also be dispatched as a custom event. An example would look like this:

class StateExample {
  constructor(public values = []) {}
}

const exampleReducer = (state: StateExample) => ({
  add: (payload) => ({ ...state, value: [...state.values, payload] }),
});

@customElement("demo-clickme")
class ClickableComponent extends LitElement {
  render() {
    const { set, get } = useReducer<StateExample>(this, exampleReducer, 0, {
      dispatchEvent: true,
    });

    return html`
            <button @click="${() =>
      set("add", 1)}">Clicked ${get()} times</button>
        `;
  }
}

// usage
html`
<demo-clickme @add="${(e: CustomEvent<StateExample>) => console.log(e.detail)}">
</demo-clickme>
`;

Subscribe to seted events

For side effects it might be interesting for you to listen to your own dispatched events. This can be done via subscribe.

Usage:

const { set, get, subscribe } = useReducer<StateExample>(
  this,
  exampleReducer,
  0,
);

subscribe((action, state) =>
  console.log("Action triggered:", action, "State:", state)
);

return html`
    <button @click="${() => set("add", 1)}">Clicked ${get()} times</button>
`;

In case you want to listen to a single action you can use the convenience method when.

const { set, get, when } = useReducer<StateExample>(this, exampleReducer, 0);

when("add", (state) => console.log("Add triggered! State:", state));

return html`
    <button @hover="${() => set("highlight")}" @click="${() =>
  set("add", 1)}">Clicked ${get()} times</button>
`;

Arguments

function description
get() => YourState Returns the current state
set(action: string, payload: unknown) => void Triggers the defined action on your reducer, passing the payload
subscribe(yourSubscriberFunction) => void Notifies subscribed functions when the state has been changed
when(action, yourSubscriberFunction) => void Notifies subscribed functions when the action has been triggered

One way flow

Both useState and useReducer have an option to updateDefauls: boolean (default: false). If set to true, it updates the state with the default values every time it is called. This is handy for one-way data binding. One example could be a list like:

const { set } = useReducer(this, listReducer, [...this.items], {
  dispatchEvent: true,
  updateDefauls: true,
});
return html`
    <input
    type="text"
    @keypress=${(e: KeyboardEvent) => {
  const element = (e.target as HTMLInputElement);
  if (element.value !== "" && e.key === "Enter") {
    set("add", element.value);
    element.value = "";
  }
}}
    />
    <ul>
    ${this.items.map((todo) => html`<li>${todo}</li>`)}
    </ul>
`;

if this is used in a page like this

<list-element .items=${get()} @add=${(e) => set(e.details)}></list-element>

Changing the attribute has been fully delegated to the user, while the control itself can still change it.

Avoiding endless state updates

Imaging a scenario where you need get some information from an endpoint you'd would want to store in the state. So you fetch it, and set it. An example would look like this:

render() {
    const {get, set} =
        useReducer<Notifications>(this, NotificationReducer, { status: "Loading" })
    fetch("/api/notifications")
        .then(response => response.json())
        .then(data => set("loaded", data))
        .catch(err => set("failed", err))

    const { status, notifications } = get()
    switch(status) {
        case "Error": return html`An error has occured`;
        case "Success": return html`<notification-table .notifications="${notifications}"></notification-table>`
    }
    return html`Please wait while loading`;
}

Unfortunately, this will lead to an endless loop. The reason is the following flow:

+--------------------------------+
|                                |
+-->render -> fetch -> set+--+

The render triggers the fetch, which triggers a set. A set however triggers a render, which triggers a fetch, which triggers a set. This triggers a render, which triggers a fetch, which triggers a set. All of that forever, and really fast.

While deploying this is great to performance test your apis, and might not be the original plan. To work around this, you might want to use the library lit-element-effect which allows you to execute a certain callback only once, or if something changes.

Install it via npm install lit-element-effect and change your code as follows:

render() {
    const {get, set} =
        useReducer<Notifications>(this, NotificationReducer, { status: "Loading" })
    useOnce(this, () => {
        fetch("/api/notifications")
            .then(response => response.json())
            .then(data => set("loaded", data))
            .catch(err => set("failed", err))
    })

    const { status, notifications } = get()
    switch(status) {
        case "Error": return html`An error has occured`;
        case "Success": return html`<notification-table .notifications="${notifications}"></notification-table>`
    }
    return html`Please wait while loading`;
}

With this little addition it is ensured that the fetch will be called only once. Accordingly, if you want to call the fetch on a property change only, use the useEffect hook as follows:

@property()
user: string

render() {
    const {get, set} =
        useReducer<Notifications>(this, NotificationReducer, { status: "Loading" })
    useEffect(this, () => {
        fetch(`/api/notifications/${this.user}`)
            .then(response => response.json())
            .then(data => set("loaded", data))
            .catch(err => set("failed", err))
    }, [this.user])

    const { status, notifications } = get()
    switch(status) {
        case "Error": return html`An error has occured`;
        case "Success": return html`<notification-table .notifications="${notifications}"></notification-table>`
    }
    return html`Please wait while loading`;
}

Workflows

Workflows allow you to create longer-running activities in your frontends. Think about an app that is setup as a temporary chat, where the flow might look like this:

![img/workflow.png](Flow of creating a temporary chat)

With the workflow hook, and the async rendering features for pure-lit, this implementation would look like this:

import { pureLit, useOnce, useWorkflow } from "pure-lit";
import { html } from "lit";
import { io } from "https://cdn.socket.io/4.3.1/socket.io.esm.min.js";
import { hours } from "./duration";
import "./components";

const socket = io("http://localhost:3001");

// The reducer for the user
const userReducer = () => ({
  createUser: async (userName) => ({ userName }),
  deleteUser: async (userName) => undefined,
});

// the reducer for managing the chat
const chatReducer = (state) => ({
  joinChat: (id) => Promise.resolve({ id }),
  sendMessage: async (message) => {
    socket.emit("message", message);
    return state;
  },
  receiveMessage: async (message) => {
    return {
      ...state,
      messages: [
        ...(state.messages || []),
        message,
      ],
    };
  },
  leaveChat: async () => undefined,
});

pureLit("easy-chat", async (element) => {
  const workflow = useWorkflow(element, {
    user: { reducer: userReducer },
    chat: { reducer: chatReducer },
  });

  console.log("history", workflow.history());
  return await workflow.plan({
    // this will be triggered unless we have a
    //  projection for the user different from 
    //  the initial value
    user: async () => {
      return html`<create-user @onCreate=${({ detail: userName }) => {
        workflow.addActivity("createUser", userName);
        workflow.addCompensation("deleteUser", userName);
      }}></create-user>`;
    },
    // Once we have a user, the second part
    //  of the plan is executed, waiting for a 
    //  projection of the chat
    chat: async () => {
      // if the user does not join a chat in hour, delete the account
      workflow.after(hours(1), {
        type: "addActivity",
        args: ["joinChat"],
      }, async () => await workflow.compensate());
      return html`<chat-list @onJoin=${({ detail: chat }) => {
        workflow.addActivity("joinChat", chat);
        workflow.addCompensation("leaveChat", chat);
      }}></chat-list>`;
    },
    // This fallback at the end is triggered 
    //  whenever all previous plans are 
    //  executed
    "": async () => {
      // if the user has not participated for an hour, leave the chat and delete the account
      const registerTimeout = () => {
        workflow.after(hours(1), {
          type: "addActivity",
          args: ["sendMessage"],
        }, async () => await workflow.compensate());
      };

      useOnce(element, () => {
        socket.on("message", (stream) => {
          workflow.addActivity("receiveMessage", stream);
        });
        registerTimeout();
      });

      const { userName } = workflow.projections("user");
      const { id, messages } = workflow.projections("chat");
      return html
        `<chat-window id="${id}" userName="${userName}" .messages="${messages}" @onSendMessage=${(
          { detail: message },
        ) => {
          workflow.addActivity("sendMessage", { id, message, userName });
          registerTimeout();
        }}></chat-window>`;
    },
  });
});

Note that you don't have to use the plan, you could as well write

if (!workflow.projection("user")) {
    return html`<create-user...`
}
if (!workflow.projection("chat")) {
    return html`<chat-list...`
}
return html`<chat-window...`