Signals and Effects Using Vanilla JavaScript & Web APIs

Photo by Milan De Clercq on Unsplash

Photo by Milan De Clercq on Unsplash

I’m a strong advocate for “you don’t need to lock yourself in a web framework ecosystem to take advantage of their amazing features”. JavaScript and Web Standards alone allow you to replicate anything independently using Web APIs.

You are probably familiar with React useState and useEffect APIs.

const [count, updateCount] = useState(0);

useEffect(() => {
  console.log(count);
}, [count])

updateCount(10);

Or SolidJs createSignal and createEffect APIs.

const [count, updateCount] = createSignal(0);

createEffect(() => {
  console.log(count());
})

updateCount(10);

They are both amazing ways to allow you to define reactive data that you can create side effects around and that integrate well with DOM rendering. In both libraries, you can use these states directly in JSX which is then compiled to HTML allowing the DOM to update whenever they change.

<p>Count: {count}</p>

Because all these libraries require you to compile code to get the final JavaScript and HTML, they turn me off completely.

There is currently a proposal to bring signals to JavaScript but until then, let me show you a way to get these using JavaScript and widely available web API.

Event-driven nature of the web

The web is event-driven and thats as close to reactivity as it gets and reactivity is just a subset of an event-driven system.

If we were to create something using plain JavaScript and DOM API that reacts to data change we would do something like this:

let count = 0;

const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = `Count: ${count}`;

btn.addEventListener('click', () => {
  count += 1;

  btn.textContent = `Count: ${count}`;
})

document.body.append(btn);

This does the trick but comes with lots of drawbacks:

  • The action is stuck with the responsibility of updating the data and the things that depend on the data;
  • The code is declarative and requires a lot of DOM manipulation and rendering handling on our part to make sure everything flows smoothly;

The benefit is that you have full control of everything which you sacrifice when working with web framework and libraries.

EventTarget API

The EventTarget API is a well-supported API that allows us to create things that emit events and that we can subscribe to. If you ever used the addEventListner you already used this API.

Because it is available everywhere, we can extend it to give it new capabilities:

class State<T = unknown> extends EventTarget {
  #value: T;

  constructor(intialValue: T) {
    super();
    this.#value = intialValue;
  }

  get value() {
    return this.#value;
  }

  update(newValue: T) {
    this.#value = newValue;
    this.dispatchEvent(new CustomEvent('change', {detail: this.#value}))
  }
}

Believe it or not, the class State above gives us everything we need to start our signal/state journey.

  • It takes an initial value T and exposes a value getter we can read it;
  • it exposes a update method that takes a new value T that we can use to update our state value;
  • it emits a change event we can listen to, to react to the changes
const count = new State(0);

const handleCountChange = (event) => {
  console.log(event.detail)
}

count.addEventListener('change', handleCountChange)

count.update(10);

count.removeEventListener('change', handleCountChange)

Looks familiar? Do you see how easy it is to create data that we can add event listener to?

But we can do better. Let’s add a subscribe method that simplifies how we add and remove event listeners:

subscribe(cb: (value: T) => void, opt?: EventListenerOptions) {
  const handler = ((event: CustomEvent) => {
    cb(event.detail)
  }) as EventListenerOrEventListenerObject;

  this.addEventListener('change', handler, opt);

  return () => {
    this.removeEventListener('change', handler, opt);
  }
}

The subscribe method ensures that the addEventListener and removeEventListener receive the same handler function which is required to remove the listener. It also allows us to provide event listeners options which can be very handy, especially the once and signal options.

With this subscribe method, we can update our code as follows:

const count = new State(0);

// only get 1 value --------
count.subscribe(value => {
  console.log(value)
}, {once: true})

// abortable event --------
const controller = new AbortController();

count.subscribe(value => {
  console.log(value)
}, {signal: controller.signal})

// unsubscribable ------
const unsubscribeFromCount = count.subscribe(value => {
  console.log(value)
});

count.update(10); // update and broadcast

unsubscribeFromCount(); // unsubscribe

controller.abort(); // abort events

But we don’t need to stop here, but let’s talk about handling side effects first.

Side Effects

Whenever we have data we often have side effects and setups around them:

  • Load: we need to get the initial data from somewhere, often enough via an asynchronous action. eg: read data from LocalStorage, fetch data from API.
  • Transformation: we need to transform the data to fit the needs of the project. e.g.: map API data response, filter data, change it to a different data type.
  • Actions: we need to perform additional actions after the data changes. eg: save in LocalStorage, call an API, trigger other state changes.

For data transformations, we can extend our Signal class with a compute method that will always keep a transformed data version of our state ready to be used anytime.

interface StateCompute<A = unknown> {
  readonly value: A;
  subscribe: (cb: (value: A) => void, opt?: EventListenerOptions) => (() => void);
}

compute<A>(cb: (value: T) => A) {
  let pendingUpdate = false;
  let cachedValue: A = cb(this.value);

  this.subscribe(() => {
    pendingUpdate = true;
  })

  const self = this;

  return {
    get value() {
      if(pendingUpdate) {
        cachedValue = cb(self.value);
        pendingUpdate = false;
      }

      return cachedValue;
    },
    subscribe(sub: (value: A) => void, opt?: EventListenerOptions) {
      return self.subscribe(() => {
        sub(this.value)
      }, opt)
    }
  } satisfies StateCompute<A>
}

You can see that this compute method takes a function that does the data transformation and returns new data that we can cache. We subscribe to the value to mark whether we need to compute the data again and this so we don’t keep computing data in the background if not necessary.

When this data is read, we check if it needs to be computed, and only then, compute the data and cache the result. This lazy approach ensures we don’t waste resources on meaningless computations if the data is not being used.

const evenOrOddCount = count.compute((c) => c % 2 == 0 ? 'even' : 'odd');

Additionally, the subscribe method allows us to subscribe to the computed value which is just a shortcut to subscribing to the underlying value but gives us the transformed value instead. This will come in handy in the next section.

const doubleCount = count.compute((c) => c * 2);

const unsub = doubleCount.subscribe(dc => console.log('Count doubled:', dc));

unsub();

Effect

We already have the subscribe method we can use to perform side effects. The problem with that is that we will need to subscribe to every state and track things between states in case a side effect requires multiple states.

We can fix that by introducing a way to perform side effects that require multiple states by introducing the effect API.

function effect<A>(cb: (prevValue?: A) => A, dependencies: Array<State | StateCompute> = []) {
  let prevValue: A = cb();

  const handler = () => {
    prevValue = cb(prevValue);
  };

  const unsubs: Array<() => void> = [];

  dependencies.forEach(state => {
    unsubs.push(state.subscribe(handler))
  })

  return () => {
    unsubs.forEach(unsub => unsub())
  }
}

This effect function takes a callback that gets called with the previous value — in case it returns something —, and takes a list of dependencies it can subscribe to. Consequently, it returns a function to allow you to clear this effect as needed.

effect(() => {
  // runs once because no dependencies are provided
})

const unsub = effect(() => {
  // runs every time the doubleCount needs to be updated
  // until unsubscribed from
  console.log(doubleCount.value);
}, [doubleCount])

unsub();

This simple effect allows you to perform wonders by spreading them in your codebase whenever you need reactive data side effects. More importantly, this allows you to separate the data from the code that performs an action and the one that needs to react to it.

Capabilities

This simple State and effect combo allows you to create things like state stores and ditch state management libraries like Redux or RxJs.

// todo.store.ts

export const todoStatus = new State('idle');
export const todos = new State([]);

effect(async () => {
  todoStatus.update('loading')
  // load the data from LocalStorage and update "todos"
  todoStatus.update('loaded')
})

effect(async () => {
  if(todoStatus.value === 'loaded') {
    // save todos back to the LocalStorage
  }
}, [todos, todoStatus]);

// expose actions
export const createTodo = (name: string) => {
  todos.update([...todos.value, {
    id: crypto.randomUUID(),
    name,
    status: 'pending'
  }])
}

export const updateTodo = (id: string, data: Partial<Omit<Todo, 'id'>>) => {
  todos.update(todos.value.map(todo => {
    if(todo.id === id) {
      return {...data, id: todo.id}
    }

    return todo;
  }))
}

export const deleteTodo = (id: string) => {
  todos.update(todos.value.filter(todo => todo.id !== id))
}

The above simple to-do store separates the action from the side effects ensuring every function is only focused on doing one thing leaving things to happen in the background.

Using individual files, we can create simple action stores that expose only what's needed so you can keep full control of the data flow behind the scenes.

When working with DOM, we can create side effects to update the DOM where needed separating the code that makes the data changes and the rendering.

const count = new State(0);

const btn = document.createElement('button');
btn.type = 'button';

effect(() => {
  btn.textContent = `Count: ${count.value}`;
}, [count])

btn.addEventListener('click', () => {
  count += 1;
})

document.body.append(btn);

More importantly, we can use this to enhance web components to have internal states in addition to responding to attribute updates. Even better, we can create function components to better organize our projects.

interface CountButtonProps {
  count?: number;
}

const CountButton = (props) => {
  const count = new State(props.count ?? 0);
  const temp = document.createElement('template');

  temp.innerHTML = `
    <button type="button">Count: ${count.value}</button>
  `;

  const [btn] = temp.content.children;

  effect(() => {
    btn.textContent = `Count: ${count.value}`
  }, [count])

  btn.addEventListener('click', () => {
    setCount(prev => prev + 1)
  })

  return temp.content;
}

document.body.append(CountButton())

There is no limit to what you can use this for. It all depends on your creativity and needs.

Next Steps

To make this even better, we can integrate this capability with DOM so we do not need to manually handle the DOM. Something like this:

const [count, setCount] = state(0);

const handleClick = () => {
  setCount(prev => prev + 1);
}

const temp = html`
  <button type="button" onclick="${handleClick}">${count}</button>
`;

temp.render(document.body);

This is all possible by using JavaScript Functions and tagged template literal along with everything I just showed you.

I created a small (9kb compressed) library that allows you to do just that. I call it Markup and I'm currently working towards v1 release.

Markup Reactive HTML Templating System by Before Semicolonmarkup.beforesemicolon.com

My point is, that you can learn JavaScript and Web APIs and with a little creativity, you can build anything without using any web framework saving you from the complexity, and ecosystem lock, while retaining 100% control of every detail of your code.

Even if you choose the web framework route, it is never a waste of time to put effort into understanding what plain JavaScript, Web Standards & APIs allow you to do. I would further argue that it would make you appreciate these frameworks even more while giving you the confidence to jump into any of them to learn them quickly!

Happy coding :)

Share this article