DEV Community

Sergey Sova for Effector

Posted on

classList API in forest

Forest is a reactive JavaScript/TypeScript rendering engine based on an effector — business logic manager.
The main idea of the forest is "initialize once, never render". Here we don't have such things as to render calls like in React.

Let's show you an example:

import { h, using } from 'forest'

function App() {
  h('div', {
    text: 'Hello',
    fn() {
      h('span', { text: 'World' })
    },
  })
}

using(document.querySelector('#root'), App)
Enter fullscreen mode Exit fullscreen mode

I need to highlight 3 things:

  1. There is no JSX or another specific syntax
  2. Forest doesn't require return from components
  3. Each function/component is called only once

Let's explain:

There is no JSX or another specific syntax

JSX looks like HTML, but do more with javascript embeds. But, when we trying to pass something that not looks like attributes, we need to build tricks:

function ReactComponent(props) {
  return <div
      onClick={handleClick}
      data-someid={props}
      style={{ height: props.height }}
      {...props.extras}
    >content</div>
}
Enter fullscreen mode Exit fullscreen mode

Please note that we also have CSS custom attributes accessible from javascript. We can customize handlers setup (capture, preventDefault, stopPropagation, and so on). DOM API also supports setting many text nodes.

But JSX doesn't support them and for already solved tasks we have a very strange API.
Also, JSX requires passing all kinds of properties to the same place, to the same object, and it shifts responsibility to solve conflicts on end developers.
Also, any specific language requires installing specific tools and learning how to solve specific issues with the new language.

Data-attributes

JSX has anti-DX:

<div data-first={1} data-second={2} />
// instead of, ex:
<div data={{ first: 1, second: 2 }} />
Enter fullscreen mode Exit fullscreen mode

Look at the DOM API:

element.dataset.first = 1
element.dataset.second = 2
Enter fullscreen mode Exit fullscreen mode

Forest:

h('div', {
  data: { first: 1, second: 2 },
})
Enter fullscreen mode Exit fullscreen mode

Event handlers

JSX compels libraries to invent the wheel with the naming of the props:

<div onClick={fn1} onClickCapture={fn2} />
Enter fullscreen mode Exit fullscreen mode

But we have beautiful DOM API:

element.addEventListener('click', fn2, { capture: true })
Enter fullscreen mode Exit fullscreen mode

Forest:

h('div', {
  handler: { click: fn1 },
})
h('div', {
  handler: { config: { capture: true }, on: { click: fn2 } },
})
// or in the same element
h('div', () => {
  spec({ handler: { click: fn1 } })
  spec({ handler: { config: { capture: true }, on: { click: fn2 } } })
})
Enter fullscreen mode Exit fullscreen mode

Style properties

Ok, inline styles are not the best practice, but we have cases for them. What JSX can offer us?

<div style={{ height: 'auto', maxHeight: '100px' }} />
Enter fullscreen mode Exit fullscreen mode

I need to remind, that inline styles allows to pass css custom properties:

<div style="height: auto; max-height: 100px; --color: black"></div>
Enter fullscreen mode Exit fullscreen mode

Forest:

h('div', {
  style: { height: 'auto', maxHeight: '100px' },
  styleVar: { color: 'black' },
})
Enter fullscreen mode Exit fullscreen mode

But JSX in React with TypeScript doesn't allow passing CSS custom properties without tears.

Whole picture

function ForestComponent({ fn, someId, height }) {
  h('div', {
    handler: { click: handleClick },
    data: { someId },
    style: { height: height },
    text: 'content',
    fn,
  })
}
Enter fullscreen mode Exit fullscreen mode

Note, that each kind of properties and attributes is separated from each other, we don't need to solve conflicts. But also, in the forest way in the most cases we don't need props at all, just call spec() inside fn(){}:

function ForestComponent({ fn }) {
  h('div', {
    text: 'content',
    fn,
  })
}

ForestComponent({
  fn() {
    spec({
      handler: { click: handleClick },
      data: { someId },
      style: { height: height },
    })
  },
})
Enter fullscreen mode Exit fullscreen mode

Yep, we like OCP from SOLID, and props allow it. But as you can see, you HAVE a choice.

Forest doesn't require return from components

React and JSX requires returning some elements from components because this library builds a diff-tree to compare with the previous view-state. Forest uses the same mechanism as react-hooks to solve the same problem.

It is called declarative stack-based DOM API. Proof of concept implementation.

When you called using with some App function, forest saves each call of h, spec, list, and other methods to the stack, it looks like virtual DOM, but you don't need to compare tree with each other. There is no render. Forest knows each point where is reactivity is applied: the user just passed effector Store instead of a literal value. When the store is updated, forest batches change and apply it to a DOM with 60 frames per second rate. Concurrent rendering out from the box.

This is why each function/component is called only once. And this is why you as a forest user need to change your habits to design forest components. You can't just put if where you want, you need to use reactive Store and visible properties to declaratively show/hide any element you want.

What was before classList

I need to explain how the spec() method works.

h("input", {
  attr: { type: "number" },
  fn() {
    spec({ attr: { class: "w-full" } })
  },
})
Enter fullscreen mode Exit fullscreen mode

All method called inside fn(){} property applies on the input element created by h(). So, we can create children elements if we call h() inside fn(){}.

spec() just add new properties, handlers, and so on on the already created element. In our case we should have <input type="number" class="w-full" />.

But with string attributes, we will have some problems.

h("input", {
  attr: { class: "first" },
  fn() {
    spec({ attr: { class: "second" } })
  },
})
Enter fullscreen mode Exit fullscreen mode

What value we should set for the class attribute? First or Second? Or we should merge? If merge, what if I need to override or disable some classes?

function Component({ fn }) {
  h("input", {
    attr: { class: "w-full text-red" },
    fn() { fn() },
  })
}

Component({ fn() {
  spec({ attr: { class: "w-20 text-blue" } }) // Oops! We already set w-full and text-red
}})
Enter fullscreen mode Exit fullscreen mode

There we have the same problem with reactivity:

function Component({ $enabled }) {
  const $class = val`w-full ${$enabled.map(is => is ? 'text-red' : 'text-gray')}`;
  h('input', {
    attr: {
      class: $class,
      type: 'number',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

What do we have now?

New API allows to set up each class independently. Proposal. It is based on browser API classList.

h("input", {
  classList: ["first"],
  fn() {
    spec({ classList: ["second"] })
  },
})
Enter fullscreen mode Exit fullscreen mode

Now classes can be combined, because forest operates each class independently, instead of a string of something inside.
Also, you have reactivity out from the box:

function Component({ $enabled }) {
  h('input', {
    attr: {
      class: "w-full",
      type: 'number',
    },
    classList: {
      'text-red': $enabled,
      'text-gray': $enabled.map(is => !is),
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

Attribute class and classList will be merged. And nested spec() call also supported:

function Component({ fn }) {
  h("input", {
    classList: ["w-full", "text-red"],
    fn() { fn() },
  })
}

Component({ fn() {
  spec({
    classList: {
      "w-full": false,
      "w-20": true,
      "text-red": false,
      "text-blue": true,
    },
  })
}})
Enter fullscreen mode Exit fullscreen mode

Thank you for the reading! 🧡 ☄️
Forest is still in the development stage, you can help us improve its API or ecosystem: github.com/effector ⭐️

Top comments (0)