How to Use Excalibur.js

The library is designed to be general-purpose. To create your own reactive app, you only need to focus on changing the "Application Code" section. The Excalibur.js library code can be reused as-is.

Step 1: Define Your Own Data Store

First, decide what data your application needs. For example, to build a "Task List" app, you would define your own `initialState` object.

// 1. Define your application's initial state
const myInitialState = {
    title: 'My To-Do List',
    tasks: [
        { id: 1, text: 'Learn Excalibur.js', completed: true },
        { id: 2, text: 'Build a cool app', completed: false },
    ],
    newTaskText: '' // To hold input from a text field
};

Step 2: Create a Reactive Store

This step is the same. Pass your own state object to the `reactive()` function provided by the library.

// 2. Create a reactive store from your state
const store = reactive(myInitialState);

Step 3: Write Your UI and Actions

This is where your application comes to life. You connect your reactive data to the DOM using the `watchEffect` function. This function is the heart of the library's reactivity.

How `watchEffect` Works

Think of `watchEffect` as a special box. You put a function inside it, and the library does two things:

  1. It runs your function immediately one time.
  2. While your function is running, the library pays close attention to every property you access from a reactive store (e.g., `playerStore.user.name`). It "subscribes" your function to each of those properties.
  3. From then on, whenever one of those subscribed properties changes, the library will automatically run your function again to update the UI.

The Render Loop

Inside the `watchEffect`, you define your entire UI as an HTML string and set it using `innerHTML`. This is a simple but effective pattern:

watchEffect(() => {
    // 1. Read from the store to build the HTML string.
    //    This automatically creates subscriptions.
    appElement.innerHTML = `<h1>${playerStore.user.name}</h1>`;

    // 2. The old HTML is gone, so we must re-attach event listeners.
    document.getElementById('changeNameBtn').addEventListener('click', () => {
        // 3. Modify the store. This triggers the effect to run again.
        playerStore.user.name = 'New Name'; 
    });
});

This creates a reactive loop: an action modifies the store, which triggers the effect to re-render the UI and re-attach the listeners, ready for the next action.

Why Model it After the Vue Composition API?

The design of `reactive()` and `watchEffect()` is heavily inspired by the Vue.js Composition API. This approach was chosen for several key reasons that make it excellent for beginners and experts alike:

  • Declarative and Intuitive: You declare what the UI should look like based on the current state. You don't have to write complex, manual DOM manipulation code. The code `playerStore.user.level++` is simple, direct, and clearly states its intent. The library handles the "magic" of updating the view.
  • Low Boilerplate: Compared to other state management patterns like Redux, there are no mandatory "actions," "reducers," or "dispatchers." You can modify your state directly, which makes the learning curve much gentler and reduces the amount of code you need to write.
  • High Readability: The code is clean and easy to follow. The logic for updating state is often right next to the UI that uses it, making it easier to reason about how your application works.

The Complete Excalibur.js Library

You can copy the code below and use it in your own projects. It has no external dependencies.

// Excalibur.js - A mini reactivity library
let activeEffect = null;
const targetMap = new WeakMap();

function track(target, key) {
    if (activeEffect) {
        let depsMap = targetMap.get(target);
        if (!depsMap) {
            depsMap = new Map();
            targetMap.set(target, depsMap);
        }
        let dep = depsMap.get(key);
        if (!dep) {
            dep = new Set();
            depsMap.set(key, dep);
        }
        dep.add(activeEffect);
    }
}

function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    const dep = depsMap.get(key);
    if (dep) {
        const effectsToRun = new Set(dep);
        effectsToRun.forEach(effect => effect());
    }
}

function reactive(target) {
    const handler = {
        get(target, key, receiver) {
            const isArray = Array.isArray(target);
            if (isArray && key === 'length') {
                track(target, 'length');
            }
            const value = Reflect.get(target, key, receiver);
            track(target, key);
            if (typeof value === 'object' && value !== null) {
                return reactive(value);
            }
            return value;
        },
        set(target, key, value, receiver) {
            const oldValue = target[key];
            const hadKey = Array.isArray(target) ? Number(key) < target.length : Object.prototype.hasOwnProperty.call(target, key);
            const result = Reflect.set(target, key, value, receiver);
            if (result && oldValue !== value) {
                trigger(target, key);
                if (Array.isArray(target) && (hadKey !== (Number(key) < target.length))) {
                    trigger(target, 'length');
                }
            }
            return result;
        }
    };
    return new Proxy(target, handler);
}

function watchEffect(effect) {
    activeEffect = effect;
    effect();
    activeEffect = null;
}