SubtlefloSubtlefloSubtlefloSubtleflo

Complete Guide to Lithent Virtual DOM Library Features

December 25, 2023 (2y ago)1,010 views

Github | Homepage

Introduction

Lithent is a lightweight (4KB gzipped) JavaScript virtual DOM library that enables predictable UI development.

With clear closure-based state management and optional extension features, it flexibly handles everything from small projects to full-scale SPAs. You can use it directly from CDN without build tools, or in modern development environments with Vite and JSX.

Core Philosophy:

  • 4KB Core: Minimal core with only essential features
  • Closure-based State: State management with pure JavaScript closures, no complex APIs
  • Manual/Reactive Choice: Manual renew() control or automatic reactive lstate
  • Progressive Adoption: From static HTML to full SPA

Table of Contents

1. Basic Features

  • mount / lmount - Component creation
  • updater - Render function
  • renew - Manual updates
  • render - DOM mounting
  • Lifecycle Hooks - mountCallback, updateCallback, mountReadyCallback
  • portal - DOM position control
  • props & children - Component communication
  • ref - Direct DOM access
  • innerHTML - Raw HTML insertion
  • stateless components - Components without state
  • nextTick - Wait for DOM updates

2. Extension Features (Helper)

  • state / lstate - Reactive state
  • store / lstore - Global state
  • context / lcontext - Context sharing
  • computed - Computed values
  • effect - Side effects
  • cacheUpdate - Memoization

3. Template Options

  • JSX - Vite plugin
  • HTM Tags - Tagged template literals
  • FTags - Functional tags
  • Template Strings - Experimental templates

4. SSR

  • Server-Side Rendering - renderToString, hydration

Basic Features

Lithent provides the minimum essential features needed to use virtual DOM. Basic features alone are sufficient to build practical UIs.

mount / lmount

mount is a function that creates components. It uses closures to define state and functionality, returning an updater function that returns JSX.

Manual Mode (mount):

import { mount } from 'lithent';

const Counter = mount((renew, props) => {
  let count = 0;

  const increase = () => {
    count++;
    renew(); // Manually trigger update
  };

  return () => <button onClick={increase}>count: {count}</button>;
});

Automatic Reactive Mode (lmount):

import { lmount } from 'lithent';
import { lstate } from 'lithent/helper';

const Counter = lmount((props) => {
  const count = lstate(0);

  const increase = () => {
    count.value++; // Automatically updates
  };

  return () => <button onClick={increase}>count: {count.value}</button>;
});

Mounter Function Arguments:

  • First argument: renew - Component update function (can be omitted in lmount)
  • Second argument: props - Properties received from parent
  • Third argument: children - Child elements

The mounter runs only once when the component is first created. Variables and functions defined here are preserved in closures and can be continuously used in the updater.

updater

The function returned by the mounter is called the updater. The updater returns JSX and runs every time the component updates.

const Component = mount((renew, props) => {
  let count = 0;
  const increase = () => { count++; renew(); };

  // This function is the updater
  return (props) => (
    <button onClick={increase}>
      count: {count}, prop: {props.value}
    </button>
  );
});

The updater can optionally receive props as an argument. You can reference the mounter's props via closure, but using the updater's argument is safer to guarantee the latest props at update time.

renew

The renew function manually updates a component. It's provided as the first argument of the mounter, and when called, the updater re-runs to refresh the UI.

const Component = mount(renew => {
  let items = ['a', 'b'];

  const addItem = () => {
    items.push('c');
    renew(); // Trigger UI update
  };

  return () => (
    <div>
      <ul>{items.map(item => <li>{item}</li>)}</ul>
      <button onClick={addItem}>Add</button>
    </div>
  );
});

renew() removes unnecessary automation, allowing clear control over update timing. Using Helper's state or lstate can automate renew() calls.

render

The render function connects the virtual DOM to the actual DOM.

import { h, render, mount } from 'lithent';

const App = mount(renew => {
  let count = 0;
  return () => <div>Count: {count}</div>;
});

// Basic usage: appendChild
render(<App />, document.getElementById('root'));

// Using insertBefore
render(
  <App />,
  document.getElementById('root'),
  document.querySelector('.insert-before-me')
);

The render function returns a destroy function. Calling destroy() unmounts the component.

const destroy = render(<App />, document.getElementById('root'));

// Remove later
destroy();

Lifecycle Hooks

You can execute specific logic at component lifecycle points.

mountCallback: Runs immediately after the component is mounted to the DOM.

import { mount, mountCallback } from 'lithent';

const Component = mount(() => {
  mountCallback(() => {
    console.log('mounted');

    // Cleanup function to run on unmount
    return () => {
      console.log('unmounted');
    };
  });

  return () => <div>Component</div>;
});

updateCallback: Runs before and after component updates.

import { mount, updateCallback } from 'lithent';

const Component = mount((renew, props) => {
  updateCallback(
    () => {
      console.log('before update');
      return () => console.log('after update');
    },
    () => [props.count] // Dependencies: only runs when count changes
  );

  return ({ count }) => <div>count: {count}</div>;
});

mountReadyCallback: Runs when the virtual DOM is ready, before actual DOM mounting. Executes faster than mountCallback.

import { mount, mountReadyCallback } from 'lithent';

const Component = mount(() => {
  mountReadyCallback(() => {
    console.log('virtual dom ready, before actual mount');

    return () => {
      console.log('cleanup before unmount');
    };
  });

  return () => <div>Component</div>;
});

Execution Order: mountReadyCallback → Actual DOM mount → mountCallback

portal

Portals render child elements at a different location in the DOM.

import { mount, portal, Fragment } from 'lithent';

const Modal = mount(renew => {
  let count = 0;
  const increase = () => { count++; renew(); };

  return () => (
    <Fragment>
      <button onClick={increase}>Update</button>
      {portal(
        <div class="modal">Count: {count}</div>,
        document.getElementById('modal-root')
      )}
    </Fragment>
  );
});

Portals are useful for UIs that need to escape parent DOM hierarchy, such as modals, tooltips, and dropdowns.

props & children

Components receive data through props and children.

const Child = mount((renew, props, children) => {
  return () => (
    <div>
      <h1>{props.title}</h1>
      <div>{children}</div>
    </div>
  );
});

const Parent = mount(() => {
  return () => (
    <Child title="Hello">
      <p>This is children content</p>
    </Child>
  );
});

Props are accessible in both mounter and updater, while children are provided as the mounter's third argument.

ref

Using ref allows direct access to DOM elements.

import { mount, ref } from 'lithent';

const Component = mount(() => {
  const inputRef = ref<HTMLInputElement>();

  const focus = () => {
    inputRef.value?.focus();
  };

  return () => (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focus}>Focus Input</button>
    </div>
  );
});

The ref's .value is only set after the component is mounted.

innerHTML

The innerHTML property allows safe insertion of raw HTML.

const Component = mount(() => {
  const htmlContent = '<strong>Bold</strong> and <em>italic</em> text';

  return () => <div innerHTML={htmlContent} />;
});

Warning: Do not use user input directly in innerHTML to prevent XSS attacks.

stateless components

Components without state can be written as pure functions without mount.

// Stateless component
const Greeting = ({ name }, children) => (
  <div>
    <h1>Hello, {name}!</h1>
    {children}
  </div>
);

// Usage
const App = mount(() => {
  return () => (
    <Greeting name="World">
      <p>Welcome to Lithent</p>
    </Greeting>
  );
});

Stateless components only receive props and children, suitable for creating reusable UI pieces.

nextTick

nextTick waits until the next DOM update is complete.

import { mount, nextTick } from 'lithent';

const Component = mount(renew => {
  let count = 0;

  const increase = async () => {
    count++;
    renew();

    await nextTick();
    // DOM update is complete at this point
    console.log('DOM updated');
  };

  return () => <button onClick={increase}>count: {count}</button>;
});

nextTick uses queueMicrotask internally to execute after all re-render requests are processed.


Extension Features (Helper)

Helpers are optional extensions built on top of basic features for more convenient use. You can import only the Helpers you need.

All Helper implementation code can be found in the GitHub repository.

state / lstate

The state Helper creates reactive state that automatically calls renew() when values change.

Manual Mode (state):

import { mount } from 'lithent';
import { state } from 'lithent/helper';

const Counter = mount(renew => {
  const count = state(0, renew);

  const increase = () => {
    count.value++; // renew() is automatically called
  };

  return () => <button onClick={increase}>count: {count.value}</button>;
});

Automatic Mode (lstate):

import { lmount } from 'lithent';
import { lstate } from 'lithent/helper';

const Counter = lmount(() => {
  const count = lstate(0); // No renew argument needed

  return () => <button onClick={() => count.value++}>count: {count.value}</button>;
});

state is implemented with a simple getter/setter pattern:

export const state = <T>(value: T, renew: () => boolean) => {
  let result = value;

  return {
    get value() { return result; },
    set value(newValue: T) {
      result = newValue;
      renew();
    }
  };
};

store / lstore

store manages global state shared by multiple components.

import { mount } from 'lithent';
import { store } from 'lithent/helper';

// Create store
const useSharedStore = store({
  text: 'shared text',
  count: 0
});

const Component1 = mount(renew => {
  const sharedStore = useSharedStore(renew);

  const changeText = (e) => {
    sharedStore.text = e.target.value;
  };

  return () => <input value={sharedStore.text} onInput={changeText} />;
});

const Component2 = mount(renew => {
  // Subscribe to specific properties only
  const sharedStore = useSharedStore(renew, (store) => [store.text]);

  return () => <div>{sharedStore.text}</div>;
});

lstore (Automatic Mode):

import { lmount } from 'lithent';
import { lstore } from 'lithent/helper';

// lstore returns a { useStore, watch } object
const counterStore = lstore({ count: 0 });

const Display = lmount(() => {
  // Use useStore() in lmount
  const counter = counterStore.useStore();
  return () => <div>Count: {counter.count}</div>;
});

const Controls = lmount(() => {
  const counter = counterStore.useStore();
  return () => <button onClick={() => counter.count++}>+</button>;
});

lstore returns { useStore, watch }. useStore automatically subscribes in lmount, while watch is used in mount when manually passing renew.

Using store allows sharing state across multiple components without prop drilling.

context / lcontext

context passes data through the component tree. Unlike store, each Provider can have independent values.

Creating context:

import { mount } from 'lithent';
import { createContext } from 'lithent/helper';

// 1. Define and create Context type
type ThemeContext = {
  theme: string;
};

const themeContext = createContext<ThemeContext>();
const { Provider, contextState, useContext } = themeContext;

// 2. Create state with contextState in Provider
const App = mount(renew => {
  // Create state with contextState (recommended without renew)
  const themeState = contextState('light');

  return () => (
    <Provider theme={themeState}>
      <Button />
      <ThemeToggle />
    </Provider>
  );
});

// 3. Access with useContext in Consumer
const Button = mount(renew => {
  const ctx = useContext(themeContext, renew);

  return () => (
    <button style={{ background: ctx.theme.value === 'dark' ? '#333' : '#fff' }}>
      Click me
    </button>
  );
});

const ThemeToggle = mount(renew => {
  const ctx = useContext(themeContext, renew);

  const toggleTheme = () => {
    ctx.theme.value = ctx.theme.value === 'light' ? 'dark' : 'light';
  };

  return () => <button onClick={toggleTheme}>Toggle Theme</button>;
});

Selective Subscription:

// Subscribe to specific keys only
const ctx = useContext(themeContext, renew, ['theme']);

lcontext (Automatic Mode): lcontext is context for lmount components. Renew management is automated for convenience.

computed

computed pre-processes complex calculations to keep templates clean.

import { mount } from 'lithent';
import { state, computed } from 'lithent/helper';

const Component = mount(renew => {
  const count = state(0, renew);

  const doubleCount = computed(() => count.value * 2);
  const quadCount = computed(() => doubleCount.value * 2);

  return () => (
    <div>
      <p>count: {count.value}</p>
      <p>double: {doubleCount.value}</p>
      <p>quad: {quadCount.value}</p>
      <button onClick={() => count.value++}>+</button>
    </div>
  );
});

Computed values are getter-only and recalculated whenever the updater runs.

effect

effect is a Helper that combines mountCallback and updateCallback to manage side effects.

import { mount } from 'lithent';
import { state, effect } from 'lithent/helper';

const Timer = mount(renew => {
  const seconds = state(0, renew);
  let intervalId: number;

  effect(
    // forward: Execute side effect
    () => {
      intervalId = setInterval(() => {
        seconds.value += 1;
      }, 1000);
    },
    // backward: Cleanup (optional)
    () => {
      clearInterval(intervalId);
    },
    // dependencies: Function returning dependency array (optional)
    () => [] // Empty array = run only on mount
  );

  return () => <div>Seconds: {seconds.value}</div>;
});

Argument Structure:

  • First argument (forward): Function that executes the side effect
  • Second argument (backward): Cleanup function (optional, can be undefined)
  • Third argument (dependencies): Function returning dependency array (optional, defaults to empty array)

Dependency Management:

const count = state(0, renew);

// Empty array: run only on mount
effect(() => console.log('Once'), undefined, () => []);

// Run when specific value changes
effect(() => console.log('Count changed'), undefined, () => [count.value]);

Important: Cleanup is a separate second argument, not a return from forward.

cacheUpdate

cacheUpdate prevents re-rendering if specific values haven't changed.

import { mount } from 'lithent';
import { state, cacheUpdate } from 'lithent/helper';

const Component = mount(renew => {
  const count1 = state(0, renew);
  const count2 = state(0, renew);

  return cacheUpdate(
    () => [count1.value], // Only detect count1
    () => (
      <div>
        count1: {count1.value}, count2: {count2.value}
        <button onClick={() => count1.value++}>+count1</button>
        <button onClick={() => count2.value++}>+count2 (no rerender)</button>
      </div>
    )
  );
});

Even if count2 changes, the UI won't update, preventing unnecessary renders.


Template Options

Lithent supports various template syntaxes.

JSX

When using with Vite, you can use JSX directly with the official plugin.

npm install -D @lithent/lithent-vite
// vite.config.ts
import { defineConfig } from 'vite';
import lithent from '@lithent/lithent-vite';

export default defineConfig({
  plugins: [lithent()]
});

TypeScript configuration:

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "lithent"
  }
}

HTM Tags

Using lTag allows JSX-like syntax without build tools.

<script src="https://cdn.jsdelivr.net/npm/lithent/dist/lithent.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent/tag/dist/lithentTag.umd.js"></script>

<script>
  const { render, mount, Fragment } = lithent;
  const { lTag } = lithentTag;

  const Component = mount(renew => {
    let count = 0;
    const increase = () => { count++; renew(); };

    return () => lTag`
      <${Fragment}>
        <div>count: ${count}</div>
        <button onClick=${increase}>Increase</button>
      <//>
    `;
  });

  render(lTag`<${Component} />`, document.getElementById('root'));
</script>

lTag is HTM pre-bound to Lithent's h function.

FTags

fTags defines virtual DOM using function calls.

<script src="https://cdn.jsdelivr.net/npm/lithent/dist/lithent.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lithent/ftags/dist/lithentFTags.umd.js"></script>

<script>
  const { render } = lithent;
  const { fTags, fMount, fFragment } = lithentFTags;
  const { div, button, p } = fTags;

  const Counter = fMount(renew => {
    let count = 0;
    const increase = () => { count++; renew(); };

    return () => fFragment(
      p(`count: ${count}`),
      button({ onClick: increase }, 'Increase')
    );
  });

  render(Counter(), document.getElementById('root'));
</script>

fTags has the advantage of maintaining type safety without build tools.

Template Strings

You can use additional Vue-like directive syntax with JSX through the @lithent/lithent-vite plugin options. It works exactly like JSX, but with additional directives such as l-if, l-else, and l-for.

⚠️ Warning: This feature is still in experimental development stage.

npm install -D @lithent/lithent-vite
// vite.config.ts
import { defineConfig } from 'vite';
import lithent from '@lithent/lithent-vite';

export default defineConfig({
  plugins: [
    lithent({
      template: 'string' // Enable Template Strings mode
    })
  ]
});
import { mount } from 'lithent';

const Component = mount(renew => {
  let count = 0;
  const increase = () => { count++; renew(); };

  return () => (
    <div>
      <p l-if={count > 0}>Count is positive: {count}</p>
      <p l-else>Count is zero or negative</p>
      <button l-on:click={increase}>Increase</button>
    </div>
  );
});

Supported directives: l-if, l-else, l-for, l-on:*, l-bind:*, etc.


SSR

Lithent supports server-side rendering and hydration.

renderToString

Generates HTML strings on the server.

import { renderToString } from 'lithent/ssr';
import { App } from './App';

// Express example
app.get('/', (req, res) => {
  const html = renderToString(<App />);

  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
});

hydration

Attaches events to server-rendered HTML on the client.

import { hydration } from 'lithent/ssr';
import { App } from './App';

// Client
hydration(<App />, document.getElementById('root'));

Hydration optimizes initial load performance by reusing existing DOM.

renderWithHydration

A universal render function that works on both server and client.

import { renderWithHydration } from 'lithent/ssr';

// Acts as renderToString on server
// Acts as hydration on client
const result = renderWithHydration(<App />, containerElement);

Conclusion

Lithent is designed around three core principles: small size, clear control, and selective extension.

  • Build complete UIs with 4KB core
  • Predictable closure-based state management
  • Selectively use only needed Helpers
  • Various template options: JSX, HTM, FTags, etc.
  • SSR and hydration support

For more details, see the official documentation.

Project Links: