Skip to content

Instantly share code, notes, and snippets.

@developit
Last active April 5, 2022 14:45
Show Gist options
  • Save developit/19163156cbea17760c11d597ae692a57 to your computer and use it in GitHub Desktop.
Save developit/19163156cbea17760c11d597ae692a57 to your computer and use it in GitHub Desktop.

preact-livedata

Experimental reactive data components for Preact.

This a result of me playing around with Kotlin/Jetpack Compose for a bit, and wondering why our solutions for data and reactivity in (p)react couldn't be at least as elegant or terse as Kotlin's LiveData.

Overview

The general premise of this library is that accessing the properties of a Reactive Object automatically subscribes the current component to mutations of those properties:

let user = reactive({ name: 'Bob' });

const Greeting = () => <h1>Hello, {user.name}!</h1>;

render(<Greeting />, document.body);

user.name = 'Alice';
// ^ Greeting is automatically re-rendered with `user.name === 'Alice'`

API

reactive(obj: object): Returns a reactive version of the given object. Accessing its properties automatically subscribes the current component to updates for those properties.

useReactive(obj: object, deps: any[]): A memoized hook version of reactive(), useful for non-factory components.

defineProps(props?: string[]): Returns a reactive object containing the given props (or all props). The returned object will update without re-rendering if its containing component is passed new props.

component((props) => (props) => JSX): Creates a factory component (see below)

Factory Components

Factory components are components that consist of an outer "init" function, which returns an inner "render" function:

const Foo = component((props, update) => {
  // this is the setup function, which runs only on the first render.
  let name = props.name || 'Bob';
  let setName = e => update(name = e.target.value);

  return props => (
    // this is the render function, which runs on _every_ render.
    <div>
      <h1>Hello {name}</h1>
      <label>Name: <input value={name} onInput={setName} /></label>
    </div>
  );
});

Complete Example

import { component, defineProps, reactive, computed } from 'preact-livedata';
import { render } from 'preact';

const initialTodos = [
  { text: 'one', done: false },
  { text: 'two', done: false },
  { text: 'three', done: false }
];

const App = component(() => {
  const state = reactive({ visibility: 'all' });

  return () => (
    <div>
      {["all", "active", "completed"].map(vis =>
        <label>
          <input type="radio" value={vis} checked={state.visibility === vis} onClick={() => state.visibility = vis} />
          {' ' + vis}
        </label>
      )}
      <TodoList {...state} />
    </div>
  );
});


const TodoList = component(() => {
  const props = defineProps(['visibility']);

  const todos = reactive(initialTodos);

  const filteredTodos = computed(() => todos.filter(todo =>
    props.visibility === 'all' || (props.visibility === 'active' ? !todo.done : todo.done)
  ));

  const toggle = todo => todo.done = !todo.done;

  const addInput = useRef();
  const add = e => {
    e.preventDefault();
    let input = addInput.current;
    todos.push({ text: input.value, done: false });
    input.value = '';
  };

  return () => (
    <>
      <ul>
        {filteredTodos.value.map(todo =>
          <Todo key={todo.text} todo={todo} onChange={toggle} />
        )}
      </ul>
      <form onSubmit={add}>
        <input placeholder="Add ToDo... [enter]" ref={addInput} />
        <button type="submit">Add</button>
      </form>
    </>
  );
});


const Todo = component(() => {
  const { todo, onChange } = defineProps();

  const toggle = () => onChange(todo);

  return () => (
    <li>
      <label>
        <input type="checkbox" checked={todo.done} onClick={toggle} />
        {' ' + todo.text}
      </label>
    </li>
  );
});

render(<App />, document.body);
{
"version": "0.1.0",
"name": "preact-livedata",
"main": "./preact-livedata.js",
"exports": "./preact-livedata.js",
"repository": "gist:597ae692a57"
}
import { Component, options } from 'preact';
import { useMemo } from 'preact/hooks';
// Factory Component helper for Preact:
export function component(f) {
return function(p) {
return (this.r || (this.r = f(p, this.setState.bind(this,{}))))(p);
};
}
// Bonus: when updating from reactive data, don't re-render subtrees.
let shallow = 0;
Component.prototype.shouldComponentUpdate = function(props, state) {
if (!shallow || !COMPONENTS.has(this) || state._ !== this.state._) return true;
for (let i in state) if (i !== '_') return true;
for (let i in props) {
if (typeof props[i] === 'function' && typeof this.props[i] === 'function') continue;
if (this.props[i] !== props[i]) return true;
}
for (let i in this.props) if (!(i in props)) return true;
return false;
};
let currentComponent;
let oldRender = options.__r;
options.__r = vnode => {
currentComponent = vnode.__c;
if (oldRender) oldRender(vnode);
};
let oldDiffed = options.diffed;
options.diffed = vnode => {
currentComponent = null;
if (oldDiffed) oldDiffed(vnode);
};
let oldUnmount = options.unmount;
options.unmount = vnode => {
let c = vnode.__c;
if (c) {
const index = COMPONENTS.get(c);
if (index) {
for (let sub of index) sub.delete(c);
COMPONENTS.delete(c);
}
}
if (oldUnmount) oldUnmount(vnode);
};
const LIVE_MAP = new WeakMap();
const SUBS = new WeakMap();
const PROXIES = new WeakSet();
const COMPONENTS = new WeakMap();
const RESOLVED = Promise.resolve();
export function useReactive(data, deps) {
return useMemo(() => reactive(typeof data === 'function' ? data() : data), deps || []);
}
export function reactive(obj) {
if (typeof obj !== 'object' || !obj) return obj;
if (PROXIES.has(obj)) return obj;
let live = LIVE_MAP.get(obj);
if (live) return live;
SUBS.set(obj, new Map());
live = new Proxy(obj, proxy);
LIVE_MAP.set(obj, live);
PROXIES.add(live);
return live;
}
export function computed(fn) {
let pending = 0;
return useMemo(() => {
let sub = {
fn,
value: undefined,
setState(s) {
if (!pending++) RESOLVED.then(sub.update);
},
update() {
pending = 0;
live.value = sub.run();
},
run() {
let cc = currentComponent;
currentComponent = sub;
try {
return sub.fn();
} finally {
currentComponent = cc;
}
},
valueOf() {
return sub.value;
}
};
let live = reactive(sub);
live.value = sub.run();
return live;
}, []);
}
export function defineProps(which) {
return useMemo(() => {
const props = Object.assign({}, currentComponent.props);
const liveProps = reactive(props);
function update(p) {
if (which) for (let i of which) if (props[i] !== p[i]) liveProps[i] = p[i];
else for (let i in p) if (props[i] !== p[i]) liveProps[i] = p[i];
}
currentComponent.componentWillReceiveProps = update;
return liveProps;
}, []);
}
let c = 0;
const proxy = {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (currentComponent) {
let subs = SUBS.get(target);
let c = subs.get(currentComponent);
if (!c) {
subs.set(currentComponent, c = new Map());
let index = COMPONENTS.get(currentComponent);
if (!index) {
COMPONENTS.set(currentComponent, index = new Set());
}
index.add(subs);
}
c.set(prop, value);
}
return reactive(value);
},
set(target, prop, value, receiver) {
let subs = SUBS.get(target);
subs.forEach((values, component) => {
if (prop == null || values.has(prop) && values.get(prop) !== value) {
shallow++;
component.setState({ _: c++ }, afterPropSub);
}
});
return Reflect.set(target, prop, value, receiver);
}
};
function afterPropSub() {
shallow--;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment