loading...

Implementing hook-like states and effects to DOM-based library

merri profile image Vesa Piittinen ・4 min read

Some five months ago I continued working on a years old idea of a lean DOM library that would only return DOM nodes and keep the state updated a bit like a Virtual DOM library, but without the Virtual DOM. Those five months ago I got the project into as far as to making conditional rendering possible, but then things got halted (as they do) and I returned back to the project only now.

The only thing that has notably changed within the last five months is my knowledge of React hooks: how they work, and what issues they have. While the design of hooks is quite clever you can see that they do workaround problems caused by the Virtual DOM. Notably, a lot of effort has to go into ensuring you keep references the same. In React class syntax references aren't an issue, but then you have to work with this a lot.

With this DOM-based library idea a lot of React ideas go to the trash bin. If we consider this:

import React from 'react'
import ReactDOM from 'react-dom'

function Component() {
    const [count, setCount] = useState(0)

    return (
        <div>
            {count}
            <button onClick={() => setCount(count + 1)}>+</button>
            <button onClick={() => setCount(count - 1)}>-</button>
        </div>
    )
}

ReactDOM.render(<Component />, document.body)

You have to remember these React facts:

  1. Component will be executed each time component is rendered
  2. useState keeps track of state
  3. onClick handlers change on each render

This logic simply doesn't work if you're returning native DOM elements, because you don't want to create new DOM elements upon each render. This also means useState equivalent has to be very different from React and adds a challenge to solve. After some hard thinking I ended up with this syntax:

/** @jsx dom */
import { dom, State } from './library'

const count = new State(0)

document.body.appendChild(
    <p>
        {count}
        <button onclick={count.set(count => count + 1)}>+</button>
        <button onclick={count.set(count => count - 1)}>-</button>
    </p>
)

Oh, I guess you notice one thing: there is no component! This is one thing that I want to embrace: since we're working with native DOM nodes there is no reason to force wrap anything into anywhere. References to functions stay always the same. The above also implies State can be independent of a containing component, which makes it possible to share globalish state in a very different manner compared to React!

In React if you want to have state that is usable in many remote places around your app you're pretty much forced to use Context API in one form or another. You have to put a Provider somewhere above in the render tree which then provides state to other parts of the app. The only other way to get state to inner components is by passing props through the tree. Or you make a custom solution.

Going back to the work-in-progress idea, another interesting thing happens with effects:

import { Effect, State } from './library'

const count = new State(0)

new Effect(
    ([count]) => {
        console.log('New value is ' + count)
        return () => console.log('Old value was ' + count)
    },
    [count]
)

count.set(1)

You can also do side-effects without component wrapping!

count as returned by new State here is not what React's hooks return with useState. Instead, as the syntax suggests, you get a special state class instead. It provides three methods: current (which holds the value), get (which returns the value, and can wrap a callback function) and set (which allows changing the value).

Putting refs together

One thing that I noticed when making State is that there really is no reason to have a Ref equivalent. So no need to createRef or useRef, simply pass a state class:

/** @jsx dom */
import { dom, Effect, State } from './library'

const input = new State()
const text = new State('')

new Effect(
    function([input, text]) {
        console.log('Text is now', text)
        if (text === 'blur') input.blur()
        return () => console.info('Old value was', text)
    },
    [input, text]
)

document.body.appendChild(
    <p>
        <input
            ref={input}
            oninput={text.set((text, event) => event.target.value)}
            placeholder="Write something"
            size="30"
            value={text}
            type="text"
        />
    </p>
)

Very short'n'sweet in many ways. Also, if you write blur into the input field you lose focus. Always pay attention!


Finally, I've had only about three hours of sleep last night as I'm being sick. So this text might be a bit confusing, but if the relevant stuff above intrigues you feel free to ask more. The code that puts all the above together is slightly sloppy and has tons of edge cases that haven't been taken care of... and detection of mounting status of a component is full of holes. But here is the CodeSandbox that I'm working on!

Not the prettiest thing around, many of the things have been put or added only to test various kinds of possible ways to break the DOM diffing!

Posted on Oct 14 '19 by:

merri profile

Vesa Piittinen

@merri

Working with all things Front End, trying to account for the core of the web (HTML+CSS+SEO+A11Y) in the world of JS (mostly React). Dislikes when DX is put before UX. Enemy of div disease.

Discussion

markdown guide