Skip to content

Instantly share code, notes, and snippets.

@culas
Last active April 26, 2024 13:00
Show Gist options
  • Save culas/96664bda9249a98f36c26bc79c1c2f62 to your computer and use it in GitHub Desktop.
Save culas/96664bda9249a98f36c26bc79c1c2f62 to your computer and use it in GitHub Desktop.
Homemade Signals and a Signal-based framework
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Basic Homemade Signals: Implementation</title>
<style>
html {
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
</body>
<script>
/*
* IMPLEMENTATION
*/
// global variable to store the current caller
let caller;
// simple implementation without cleanups and checks for infinite loops
function signal(initialValue) {
let _value = initialValue;
const observers = [];
return {
get value() {
if (caller && !observers.includes(caller)) {
observers.push(caller);
}
return _value;
},
set value(newValue) {
if (newValue !== _value) {
_value = newValue;
observers.forEach(fn => fn());
}
}
}
}
function effect(fn) {
caller = fn;
fn();
caller = undefined;
}
function computed(computation) {
return {
get value() {
return computation();
}
}
}
// inspired by: https://www.thisdot.co/blog/deep-dive-into-how-signals-work-in-solidjs
</script>
<script>
/*
* USAGE
*/
const counter = signal(0);
const double = computed(() => counter.value * 2);
const button = document.createElement('button');
const paragraph = document.createElement('p');
document.body.append(button, paragraph);
button.addEventListener('click', () => counter.value++);
// these effects are hidden by the framework,
// but it's essentially what happens with signals in a component template
const updateButton = () => button.innerText = counter.value;
effect(updateButton);
effect(() => paragraph.innerText = double.value);
</script>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Basic Homemade Signals: Signal-based Framework</title>
<style>
html {
font-family: sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
</body>
<script type="module">
import {signal, effect, computed} from 'https://cdn.jsdelivr.net/npm/@preact/signals-core@1.5.1/+esm';
/*
* FRAMEWORK
*/
class BaseElement {
/** @type {HTMLElement} */
#element;
constructor(tag_name) {
this.#element = document.createElement(tag_name);
}
children(...elements) {
elements.forEach(element => this.#element.append(element));
}
get element() {
return this.#element;
}
}
// to specify the root element, based on the islands architecture concept
const Island = (id) => {
const root = document.getElementById(id);
return (elements) => {
root.append(elements);
};
};
// creates an HTML element
// returns a function to set attributes, which then returns one to pass child elements
function getElement(tag_name) {
return (attributes = {}) => {
const el = new BaseElement(tag_name);
Object.entries(attributes).forEach(
([name, value]) => {
if (name.startsWith('on')) {
el.element.addEventListener(name.slice(2), value);
} else {
el.element.setAttribute(name, value);
}
},
);
return (...children) => {
children.forEach(child => {
if (child.brand?.description === 'preact-signals') {
if (Array.isArray(child.value)) {
effect(() => el.element.replaceChildren(...child.value));
} else {
const text_node = document.createTextNode('');
effect(() => text_node.data = child.value);
el.children(text_node);
}
} else {
el.children(child);
}
});
return el.element;
};
};
}
// helper function to simplify handling lists
const each = (array_signal, mapper) => {
return computed(() => array_signal.value.map(mapper));
};
// simulate module export
window.SignalBasedFramework = {
Island,
Button: getElement('button'),
Input: getElement('input'),
Div: getElement('div'),
P: getElement('p'),
Span: getElement('span'),
Section: getElement('section'),
Ul: getElement('ul'),
Li: getElement('li'),
each,
signal,
computed,
};
</script>
<script type="module">
// simulated import from framework module
const {Island, Button, Input, Div, P, Span, Section, Ul, Li, each, signal, computed} = window.SignalBasedFramework;
/*
* APP
*/
// global signals
const counter = signal(0);
const double = computed(() => counter.value * 2);
const name = signal('');
// we can easily create custom components with their own local state
function CounterButton() {
const myCount = signal(0);
const onclick = () => myCount.value++;
return Button({onclick})(
'You pressed me ', myCount, ' times',
);
}
function TodoItem({text, onDelete}) {
return Li()(
Div({style: 'display: flex; gap: 1rem;'})(
Span()(text),
Button({onclick: onDelete})(
'X',
),
),
);
}
function TodoApp() {
const todos = signal([]);
const currentTodo = signal();
function onSubmit() {
return todos.value = [...todos.value, currentTodo.value];
}
function onDelete(index) {
todos.value = todos.value.toSpliced(index, 1);
}
return Section()(
P()('Proof that you can build a Todo app:'),
Input({
type: 'text',
onchange: (event) => currentTodo.value = event.target.value,
})(),
Button({onclick: () => onSubmit()})(
'Add todo',
),
Ul()(
each(todos, (todo, i) => TodoItem({text: todo, onDelete: () => onDelete(i)})),
),
);
}
// here we hook up our app by specifying the element with ID "app" to be our island root
// inside, we can nest predefined and custom components to declaratively define our interactive UI
Island('app')(
Div()(
P()(
'This button is has its own reactive counter:',
),
CounterButton(),
P()(
'This one manipulates global state:',
),
Button({onclick: () => counter.value++})(
'Press me to do some maths!',
),
P()(
'Counter: ', counter, ' * 2 = ', double,
),
P()(
'Here we have just a regular old number input field:',
),
Input({type: 'number', value: 42})(),
P()(
'Last but not least: a reactive input field:',
),
Section()(
Input({
type: 'text',
placeholder: 'Enter your name',
oninput: (event) => name.value = event.target.value
})(),
Span()('Hello ', name, '!'),
),
TodoApp(),
),
);
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment