npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

machinetje

v0.4.0

Published

Tiny state machines for Svelte 5+

Downloads

1

Readme

Machinetje

Tiny state machines for Svelte 5+

Motivation

Machinetje in Dutch means little machine, which perfectly describes this package. It's a small state machine solution, intended to be a good match with Svelte 5+.

Machine Definition

Start by describing the possible states and their transitions, ending with the initial state. This may be done in a seperate .js/.ts file, to keep the logic seperate from your Components.

// resource-machine.js

import { machinetje } from 'machinetje';

export const resourceMachine = machinetje(
    {
//      👇 state
        idle: {
            on: {
//              👇 action    👇 target state
                download: 'loading'
            }
        },
        loading: {
            on: {
                cancel: 'idle',
                success: 'done',
                failure: 'error'
            }
        },
        done: {},
        error: {
            on: {
                retry: 'loading'
            }
        }
    },
//  👇 initial state
    'idle',
//  👇 initial context
    {
        responseText: null,
        errorMessage: null
    }
);

Use context to store data (a.k.a. extended state) into your machinetje, such as fetch responses, error messages, remaining retry attemps, etc. You can write to the context when dispatching an action, or at the start of an effect, as shown later.

Reading state, dispatching actions

In your component files, you can start a new instance of the machinetje:

<!-- SomeComponent.svelte -->

<script>
    import { resourceMachine } from './resource-machine.js';

    const resource = resourceMachine();

    let fileName = $state('');

    function download() {
        // dispatch actions to your machinetje to change its state:
        resource.dispatch('download', fileName);
        //                 👆 action   👆 action parameter
    }

    function cancel() {
        resource.dispatch('cancel');
    }
</script>

{#if resource.state === 'idle'}
    <input bind:value={fileName} />
    <button onclick={download}>Download file</button>
{:else if resource.state === 'loading'}
    <p>Downloading resource…</p>
    <button onclick={cancel}>Cancel</button>
{:else if resource.state === 'done'}
    <p>{resource.context.responseText}</p>
<!-- etc… -->

Alternatively, use the <SelectState> helper component with snippets:

<!-- SomeComponent.svelte -->

<script>
    import { SelectState } from 'machinetje';
    import { resourceMachine } from './resource-machine.js';

    const resource = resourceMachine();

    let fileName = $state('');
</script>

<SelectState machinetje={resource}>
    {#snippet idle({ dispatch })}
        <input bind:value={fileName} />
        <button onclick={() => dispatch('download')}>Download</button>
    {/snippet}
    {#snippet loading({ dispatch })}
        <p>Downloading resource…</p>
        <button onclick={() => dispatch('cancel')}>Cancel</button>
    {/snippet}
    {#snippet done({ context })}
        <p>{context.responseText}</p>
    <!-- etc… -->
</SelectState>

Effects

Effects let you write to the context of your machinetje, and interact with the world around it. Within effects, use the provided dispatch property to pass on data to the next state via actions.

// resource-machine.js

export const resourceMachine = machinetje({
    idle: {
        on: {
            download: 'loading'
        }
    },
    loading: {
        effect: loadResource,
        on: {
            cancel: 'idle',
            success: 'done',
            failure: 'error'
        }
    },
    done: {
        effect({ value, setContext }) {
            setContext({ responseText: value });
        },
    },
    error: {
        effect({ value, setContext }) {
            setContext({ errorMessage: value });
        },
        on: {
            retry: 'loading'
        },
    }
}, 'idle', { responseText: null, errorMessage: null });

async function loadResource({ value, setContext, dispatch, signal }) {
    // set a clear context to remove any potential old errors
    setContext({});

    // `value` represents the optional extra parameter provided to `dispatch`
    if (!value) {
        return dispatch('failure', 'No file name provided');
    }
    const resourceUrl = `example.com/resource/${value}.zip`;

    try {
        // use the `signal` to allow cancelation of the request
        const response = await fetch(resourceUrl, { signal });
        const responseText = await response.text();
        if (!response.ok) {
            throw new Error(responseText);
        }
        // dispatch 'success' action, and place the responseText in the context
        dispatch('success', responseText);
    }
    catch (error) {
        dispatch('failure', error.message);
    }
}

When the effect is an async function, use the provided signal to handle cancelation cases (see AbortSignal), as exampled above. This signal will be aborted whenever a dispatched action causes a state transition.

Alternatively, a sync effect can return a cleanup function:

// heavy-processing-machine.js

const heavyProcessingMachine = machinetje({
    ready: {
        on: {
            start: 'processing',
        }
    },
    processing: {
        on: {
            result: 'finished',
            cancel: 'ready',
        },
        effect: doTheHardWork
    },
    finished: {
        effect({ value, setContext }) {
            setContext({ result: value });
        }
    },
}, 'ready', { result: null });

function doTheHardWork({ dispatch }) {
    const worker = new Worker('heavy-script.js');

    worker.onmessage = (event) => {
        dispatch('result', event.data);
    };

    return cleanup() {
        worker.terminate();
    };
}

// AnotherComponent.svelte
import { heavyProcessingMachine } from './heavy-processing-machine.js';

const heavyProcessing = heavyProcessingMachine();
heavyProcessing.dispatch('start');
heavyProcessing.dispatch('cancel'); // <= causes the cleanup function to run

Setting Context

Context can be set in an effect, under the following conditions:

  • the context is set synchronously
  • the context is set before any dispatch

If these conditions are not met, the setContext call is ignored and a warning is logged to the console.

// some-machine.js

export const someMachine = machinetje({
    idle: {
        async effect({ setContext }) {
            // this works:
            setContext({ value: 'Hello' });

            // this would not update the context:
            setTimeout(() => setContext({ value: 'World' }), 1);

            // this also would not update the context:
            await somePromise();
            setContext({ value: 'World' });
        },
        on: {
            work: 'busy',
        }
    },
    busy: {
        effect({ setContext, dispatch }) {
            // this works:
            setContext({ value: 'Hello' });
            dispatch('done');

            // calls after dispatch are ignored:
            setContext({ value: 'World' }); // <= ignored
        },
        on: {
            done: 'idle',
        }
    }
}, 'idle', { value: null });

State Machine Recovery

Machinetjes can be recovered from a serialized state. Serializing a machinetje's full state is done by reading the state and context properties from the instance. The serialized state can be stored somewhere (e.g. in LocalStorage), and later used to recover the machinetje:

import { someMachine } from './some-machine.js';

const instance = someMachine();

// storing the state
const { state, context } = instance;
const serialized = JSON.stringify({ state, context });
localStorage.setItem('stored-state', serialized);

// recovering the state
const serialized = localStorage.getItem('stored-state');
const { state, context } = JSON.parse(serialized);
const recovered = someMachine(state, context);

Note that the recovered machinetje is a new instance. If the current recovered state has an effect, it will run, even if it had already run to completion in the original instance. This is so that if the machinetje is recovered in a resource loading state, for example, it will again start the effect to load the resource.

Examples

Fetching a resource

// resource-machine.js

export const resourceMachine = machinetje({
    idle: {
        on: {
            download: 'loading'
        }
    },
    loading: {
        effect: loadResource,
        on: {
            cancel: 'idle',
            success: 'done',
            failure: 'error'
        }
    },
    done: {
        effect({ value, setContext }) {
            setContext({ responseText: value });
        }
    },
    error: {
        effect({ value, setContext, context, dispatch }) {
            setContext({ errorMessage: value });
            autoRetry({ context, setContext, dispatch, value });
        },
        on: {
            retry: 'loading'
        }
    }
}, 'idle', { responseText: null, errorMessage: null, remainingAutoRetries: 2 });

async function loadResource({ setContext, dispatch, signal }) {
    setContext({});

    try {
        const response = await fetch('example.com/resource', { signal });
        const responseText = await response.text();
        if (!response.ok) {
            throw new Error(responseText);
        }
        setContext({ responseText });
        dispatch('success', responseText);
    }
    catch (error) {
        setContext({ errorMessage: error.message });
        dispatch('failure', error.message);
    }
}

function autoRetry({ context, setContext, dispatch }) {
    if (context.remainingAutoRetries <= 0) {
        return;
    }
    const remainingAutoRetries = context.remainingAutoRetries - 1;
    setContext({ remainingAutoRetries });
    dispatch('retry');
}
<!-- Resource.svelte -->

<script>
    import { resourceMachine } from './resource-machine.js';

    const resource = resourceMachine();
</script>

<SelectState machinetje={resource}>
    {#snippet idle({ dispatch })}
        <button onclick={() => dispatch('download')}>Download</button>
    {/snippet}
    {#snippet loading({ dispatch })}
        <p>Downloading resource…</p>
        <button onclick={() => dispatch('cancel')}>Cancel</button>
    {/snippet}
    {#snippet done({ context })}
        <p>{context.responseText}</p>
    {/snippet}
    {#snippet error({ dispatch, context })}
        <p>{context.errorMessage}</p>
        <button onclick={() => dispatch('retry')}>Retry</button>
    {/snippet}
</SelectState>

A simple stopwatch

// stopwatch-machine.js

export const stopwatchMachine = machinetje({
    stopped: {
        effect: ({ setContext }) => {
            setContext({ startTime: null });
        },
        on: {
            start: 'running'
        }
    },
    running: {
        effect: ({ setContext }) => {
            setContext({ startTime: new Date() });
        },
        on: {
            stop: 'stopped'
        }
    }
}, 'stopped', { startTime: null });
<!-- Stopwatch.svelte -->

<script>
    import { stopwatchMachine } from './stopwatch-machine.js';

    const stopwatch = stopwatchMachine();

    let currentTime = $state(0);

    $effect(() => {
        if (stopwatch.state !== 'running') {
            currentTime = 0;
            return;
        }

        let nextFrame;
        function renderLoop() {
            const now = Date.now();
            const elapsed = now.getTime() - stopwatch.context.startTime.getTime();
            currentTime = elapsed / 1000;
            nextFrame = requestAnimationFrame(renderLoop);
        };
        renderLoop();
        return () => cancelAnimationFrame(nextFrame);
    });
</script>

<p>{currentTime}</p>

<SelectState machinetje={stopwatch}>
    {#snippet stopped({ dispatch })}
        <button onclick={() => dispatch('start')}>Start</button>
    {/snippet}
    {#snippet running({ dispatch })}
        <button onclick={() => dispatch('stop')}>Stop</button>
    {/snippet}
</SelectState>