Michijs Logo

A lightweight vanilla JavaScript library for creating Web Components

Open in Visual Studio Code Version MIT Downloads Repo size Minzipped size Tests

Why MichiJS?

Feature MichiJS React StencilJS SvelteJS VueJS VanillaJS
Real DOM preferred over virtual DOM
Dynamic constructable stylesheets
General constructable stylesheets
JavaScript templates preferred over compiled text
Templates with JSX
Element internals support
IDE-friendly without special extensions
Attribute vs property distinction in JSX/templates
Standard Web Components 1 2
Observables/stores support 3 3 3 3
Esbuild as default bundler
TypeScript support 3
Reactive programming
Automatic component type generation
Native attributes/events support 4 5
Shadow DOM support
Custom built-in elements support
Cross-framework compatibility 6

✅ = Fully implemented
⭕ = Partially implemented
❌ = Not implemented

More details
  1. React supports Web Components starting from version 19.
  2. Vue has partial support for Web Components, but usage requires workarounds or third-party packages.
  3. Requires external packages, not a unique-state-first approach.
  4. React only supports properties and synthetic events.
  5. StencilJS supports only properties.
  6. Svelte components can be shared only through custom elements.

Getting Started

You can start by using this template to quickly set up your project. Alternatively, you can explore the Code Sandbox version for a hands-on example.

Creating components

Your first custom element

MichiJS custom elements are plain objects.

New components can be created using the jsx/tsx extension, such as MyCounter.tsx.

import { createCustomElement, EventDispatcher } from "@michijs/michijs";
import { counterStyle } from "./counterStyle";

export const SimpleCounter = createCustomElement("simple-counter", {
  reflectedAttributes: {
    count: 0,
  },
  methods: {
    decrementCount() {
      this.count(this.count() - 1);
    },
    incrementCount() {
      this.count(this.count() + 1);
    },
  },
  events: {
    countChanged: new EventDispatcher<number>(),
  },
  adoptedStyleSheets: { counterStyle },
  render() {
    this.count.subscribe(this.countChanged);
    return (
      <>
        <button onpointerup={this.decrementCount}>-</button>
        <span>{this.count}</span>
        <button onpointerup={this.incrementCount}>+</button>
      </>
    );
  },
});

Note: the .tsx extension is required, as this is the standard for TypeScript classes that use JSX.

To use this component, just use it like any other HTML element:

import '../Counter';

<my-counter oncountchanged={(ev) => console.log(`New count value: ${ev.detail}`)} />

Or if you are using JSX

import Counter from '../Counter';

<Counter oncountchanged={(ev) => console.log(`New count value: ${ev.detail}`)} />

Component Structure

A component consists of the following properties:

Property Description
attributes Allows defining attributes.
reflectedAttributes Allows defining reflected attributes and follows the Kebab case. A reflected attribute cannot be initialized with a true value.
methods Methods are functions that notify changes at the time of making the change.
adoptedStyleSheets Allows using Constructable Stylesheets. Remember that you need to use Shadow DOM to utilize Constructable Stylesheets; otherwise, it will return a style tag.
cssVariables Allows defining CSS variables.
reflectedCssVariables Allows defining reflected CSS variables and follows the Kebab case. A reflected CSS variable cannot be initialized with a true value.
computedStyleSheet Allows defining a Constructable Stylesheet that depends on the component's state. When there is no shadow root, the style will be reflected in the style attribute.
render Function that renders the component.
lifecycle
Custom Element related
willConstruct This method is called at the start of the constructor.
didConstruct This method is called at the end of the constructor.
connected This method is called when a component is connected to the DOM.
willMount This method is called right before a component mounts.
didMount This method is called after the component has mounted.
willReceiveAttributeCallback This method is called before a component does anything with an attribute.
disconnected This method is called when a component is disconnected from the DOM.
didUnmount This method is called after a component is removed from the DOM.
Form-associated Custom Element related
formAssociatedCallback Called when the browser associates the element with a form element or disassociates the element from a form element.
formDisabledCallback Called after the disabled state of the element changes, either because the disabled attribute of this element was added or removed; or because the disabled state changed on a fieldset that's an ancestor of this element. The disabled parameter represents the new disabled state of the element. The element may disable elements in its shadow DOM when it is disabled.
formResetCallback Called after the form is reset. The element should reset itself to some kind of default state. For input elements, this usually involves setting the value property to match the value attribute set in markup (or, in the case of a checkbox, setting the checked property to match the checked attribute).
formStateRestoreCallback Called in one of two circumstances: when the browser restores the state of the element (for example, after a navigation, or when the browser restarts) or when the browser's input-assist features such as form autofilling sets a value. The type of the first argument depends on how the setFormValue() method was called.
events Allows you to define an event to its parent and trigger it easily. It will be defined using lowercase. For example, countChanged will be registered as countchanged.
shadow Allows you to add a Shadow DOM. By default, it uses open mode on Autonomous Custom elements and does not use Shadow DOM on Customized built-in elements. Only these elements are allowed to use Shadow DOM.
formAssociated This tells the browser to treat the element like a form control.
extends Allows creating a Customized built-in element.
tag The tag to extend.
class The class you want to extend.

If the extends field is not provided an Autonomous custom element will be created.

Component Lifecycle

stateDiagram-v2
    [*] --> willConstruct
    willConstruct --> didConstruct
    didConstruct --> connected
    connected --> willMount: Only the first time
    willMount --> didMount
    didMount --> disconnected
    disconnected --> didUnmount
    didUnmount --> [*]
    disconnected --> connected: If element was moved
    connected --> disconnected
    didUnmount --> connected: If the element was cached

    willConstruct --> formAssociated: Only if formAssociated
    formAssociated --> didConstruct

Callbacks can be called at almost any point of the lifecycle

How This Works?

The problem with stores - the traditional approach

Libraries traditional approach is usually based on stores.

graph TD;
    subgraph Store
    A["Property A"]
    B["Property B"]
    C["Property C"]
    end
    Store --> D["Component D"];
    Store --> E["Component E"];
    Store --> F["Component F"];
    F --> H["Component H"];
    F --> I["Component I"];
    Store --> G["Component G"];

This approach brings three major issues:

const [value, setValue] = useState(0);
<input type="number" value={value} onChange={(e) => setValue(e.target.value)}/>

In this example, the value is updated every time the input changes, which, by definition, is incorrect. Why? Because "value" "Specifies the default value". This means that the value does not need to be updated after the first render since it has no effect. "But React says that you can use defaultValue!" Yes, but it's not the standard way to do it and it's one of the most common mistakes most React developers make. All this is due to not using the platform.

With Michijs the solution is:

const value = useObserve(0);

<input type="number" value={value()} onchange={(e) => value(e.target.value)}/>

Observers / Signals

Observers are a behavioral design pattern that defines a one-to-many dependency between objects. When the observable / subject undergoes a change in state, all its dependents (observers / subscribers) are notified and updated automatically with a signal.

sequenceDiagram
    box rgb(71,73,73) Observable
    participant Value
    participant Proxy
    end
    Subscriber->>Proxy: Subscribes to
    Environment->>Proxy: Request to change a value
    Proxy-->>Value: Value is different?
    Value-->>Value: Yes! Update
    Value-->>Proxy: Sends a clone of the value
    Proxy->>Subscriber: Notifies with a signal (new value)

This approach allows for much more granular updates. Instead of updating an entire component, you can update HTML elements, attributes, or a simple text node and still maintain the principle of a single source of truth.

graph TD;
    subgraph Observable
    A["Property A"]
    B["Property B"]
    C["Property C"]
    end
    A --> D["Attribute D"];
    A --> E["Text node E"];
    B --> F["Another observable F"];
    C --> G["Text node G"];

When a node is garbage collected, it will be unsubscribed in the next update.

Rendering - Static vs Dynamic

Taking the above into account, the rendering process changes drastically. Instead of rendering the entire component with each change, we render the component only once, and the changes are managed through the observables.

Example:
import { createCustomElement, useComputedObserve } from "@michijs/michijs";

createCustomElement("test-component", {
  reflectedAttributes: {
    valueA: 0,
    valueB: 1,
  },
  methods: {
    incrementValueB() {
      this.valueB(this.valueB() + 1);
    },
  },
  render() {
    const sum = useComputedObserve(() => this.valueA() + this.valueB(), [this.valueA, this.valueB]);
    return (
      <>
        <button onpointerup={this.incrementValueB}>Increment B</button>
        {/* Renders 0, but is static */}
        <span>{this.valueA()}</span>
        {/* Renders 1, but is dynamic and will change when clicking on the button */}
        <span>{this.valueB}</span>
        {/* Renders 1, but is static */}
        <span>{this.valueA() + this.valueB()}</span>
        {/* Renders 1, but is dynamic and will change when clicking on the button */}
        <span>{sum}</span>
      </>
    );
  },
});

Operators

Since all observables are objects, operators work in a different way. We support most operators without explicitly calling the getter of the observable.

  const a = useObserve(0);
  // Valid Javascript - Not valid Typescript
  const b = a + 1;

This is valid Javascript but is not valid in Typescript yet.

  const a = useObserve("Hello");
  // Valid
  const b = a + " World";

We do not support boolean operators since proxies are objects:

  const a = useObserve(false);
  // Valid - Returns 2
  const b = a() ? 1: 2;
  // Valid but wrong usage - Returns 1 since "a" is an object and evaluates "true"
  const b = a ? 1: 2;

Hooks

There are several differences between our hooks and traditional ones:

The ability to use hooks outside of component code can be beneficial for managing application-wide state, setting up global side effects, or encapsulating reusable logic in utility functions or modules.
It provides more flexibility in organizing code and separates concerns by allowing developers to centralize state management and side effects in hooks that can be reused across components or accessed from different parts of the application.

Basic hooks

useObserve

Responsible for observing changes on different types of values. Takes two arguments:

This is the most basic hook and it is the basis of the entire component structure.

If the item contains a function, it will return an observable that observes for changes in the object itself.

A function in an observable should never mutate the observable.

usePureFunction

It is used to create a memoized function that encapsulates the result of the provided callback function and updates it only when any of the dependencies change. Takes two arguments:

Example:
import { usePureFunction } from "@michijs/michijs";

const sum = usePureFunction((a, b) => a + b, [a, b]);

console.log(sum(1, 2)); // Outputs 3
console.log(sum(1, 2)); // Outputs 3 - without calling the callback - returning the cached value

useAsyncComputedObserve

It is used for computing a value and observing its changes. Takes four arguments:

Example:
import { useAsyncComputedObserve } from "@michijs/michijs";

const fetchData = useAsyncComputedObserve(
  async () => {
    const response = await fetch("https://api.example.com/data");
    return response.json();
  },
  [], // Initial value
  {
    onBeforeUpdate: () => console.log("Fetching data..."),
    onAfterUpdate: () => console.log("Data fetched:", fetchData()),
  }
);

useComputedObserve

It is used for computing a value and observing its changes. Takes three arguments:

Example:
import { useComputedObserve } from "@michijs/michijs";

const a = useObserve(2);
const b = useObserve(3);

const sum = useComputedObserve(() => a() + b(), [a, b], {
  onBeforeUpdate: () => console.log("Calculating sum..."),
  onAfterUpdate: () => console.log("New sum:", sum()),
});

console.log(sum()); // Outputs the computed sum

useStringTemplate

It is used to create a string template by interpolating dynamic values.

Example:
  const a = useObserve(3);
  // Returns an observable with initial value 'Test 3' and subscribed to a
  const b = useStringTemplate`Test ${a}`;

useWatch

A simple mechanism for watching dependencies and invoking a callback when any of them change. Takes two parameters:

Example:
import { useObserve, useWatch } from "@michijs/michijs";

const count = useObserve(0);

useWatch(() => {
  console.log(`Count has changed to: ${count()}`);
}, [count]);

// Simulating a change
count(1); // Outputs: Count has changed to: 1

useFetch

Fetches data from a URL, parses the response as JSON, and allows managing the result as an observable. Takes three parameters:

Returns: An object of type FetchResult<R>, which includes:

Example:
import { useFetch } from "@michijs/michijs";

const { promise, recall } = useFetch(async () => {
  const token = tokenCookie.token();
  const input = "/some/endpoint";
  const searchParams = { query: "example" };
  
  return {
    input: `https://api.github.com${input}`,
    searchParams,
    headers: {
      Accept: "application/vnd.github+json",
      Authorization: `${token!.type} ${token!.value}`,
      "X-GitHub-Api-Version": "2022-11-28",
    },
  };
}, [validationProps, ...(shouldWait ?? [])], {});

// Example usage of the promise
promise().then(data => {
  console.log(data); // Outputs the fetched data
}).catch(error => {
  console.error(error);
});

// To call the promise again
recall();

usePromise

Uses a promise and allows managing the result as an observable. Takes two parameters:

Returns: A PromiseResult object, which includes:

You can also use doPromise for an imperative alternative.

Example:
import { usePromise } from "@michijs/michijs";

const { promise, recall } = usePromise(async () => {
  const response = await fetch("https://api.github.com/users/octocat");
  return response.json();
}, []);

promise().then(user => {
  console.log(user); // Outputs the user data
});

// To recall the promise later
recall();

Route management hooks

useHash

The useHash hook manages the hash portion of the URL, allowing you to observe and synchronize changes between the hash value and an observable state. This is particularly useful for single-page applications (SPAs) where routing is handled client-side. Parameters:

Returns: An observable with keys of type T and boolean values.

Example:
import { useHash } from "@michijs/michijs";

// Using useHash to manage the hash state
const hashState = useHash<'#drawerOpened'>();

// Opening a drawer
hashState['#drawerOpened'](true);

useSearchParams

Facilitates the management and observation of search parameters in the URL, providing a reactive way to handle changes and update the URL accordingly.

Returns: An observable object containing the search parameters defined by the generic type T.

Example:
import { useSearchParams } from "@michijs/michijs";

const searchParams = useSearchParams<{
    textParam: string;
}>();

// To update the search parameters
searchParams.textParam("Hello");

useTitle

Allows to observe the document title. Do not use document.title use this hook instead

Example:
import { useTitle } from "@michijs/michijs";

const title = useTitle();

title('test')

Storage hooks

useStorage

Allows for observing changes in an object and synchronizing it with the browser's storage (such as localStorage). Takes two parameters:

If you want to use cookies we provide a class that acts like an storage called CookieStorage

Example:
const { lang } = useStorage({
  // Default value
  lang: navigator.language,
});

useIndexedDB

It sets up event listeners for changes in the IndexedDB database. It returns a Proxy object that intercepts property accesses and performs corresponding IndexedDB operations. IndexedDB operations are performed asynchronously and return Promises. Takes three arguments:

Example:
const storedCount = useIndexedDB<{
  counter: {
    count: number;
    id: number;
  };
}>("counter", {
  counter: {
    keyPath: "id",
  },
});

const count = useAsyncComputedObserve(
  async () => {
    return (await storedCount.counter.get(1))?.count ?? 0;
  },
  (await storedCount.counter.get(1))?.count ?? 0,
  [storedCount],
);

function decrementCount() {
  storedCount.counter.put({ count: count() - 1, id: 1 });
}
function incrementCount() {
  storedCount.counter.put({ count: count() + 1, id: 1 });
}

CSS hooks

To use css we provide functions to create Constructable Stylesheets. Our stylesheets can also subscribe to observables.

useStyleSheet

Allows to create a Constructable Stylesheet with a CSSObject.

Example:
export const counterStyle = useStyleSheet({
  ':host': {
    display: 'flex',
    flexDirection: 'row'
  },
  span: {
    minWidth: '60px',
    textAlign: 'center'
  }
});

css

Allows to create a Constructable Stylesheet with a Template String. Recomended extension for VSCode.

Example:
export const counterStyle = css`
  :host {
      display: flex;
      flex-direction: row;
  }

  span {
      min-width: 60px;
      text-align: center;
  }
`

useAnimation

Generates CSS keyframes and animation properties based on the provided keyframes and options.

Example:
const hiddenState = {
  opacity: 0,
} satisfies CSSProperties;
const shownState = {
  opacity: 1,
} satisfies CSSProperties;

const [hideKeyframe, hideProperties] = useAnimation([shownState, hiddenState], {
  duration: '2s',
  fill: 'forwards'
});
const [showKeyframe, showProperties] = useAnimation([hiddenState, shownState], {
  duration: '1s',
  fill: 'forwards'
});

export const dialogStyle = useStyleSheet((tag) => ({
  ...showKeyframe,
  ...hideKeyframe,
  [tag]: {
    ...hideProperties,
    display: 'flex',
    flexDirection: 'row',
    '[open]': showProperties
  },
}));

useTransition

Hook to generate CSS transition properties based on the provided configuration.

Example:
const opacityTransition = useTransition({
  property: ["opacity"],
  duration: "1s",
});

export const dialogStyle = useStyleSheet((tag) => ({
  [tag]: {
    ...opacityTransition,
    display: 'flex',
    flexDirection: 'row',
    opacity: 0,
    '[open]': {
      opacity: 1
    }
  },
}));

CSS module scripts

We do not provide support for this functionality yet as ESBuild does not support it yet. You can read how it works here

Components

If

Conditional rendering component. This is the only way to do it dynamically.

Title

Title component for dynamically updating the document's title.

Redirect

Redirect component for navigating to a different URL or location.

Host

Allows to set attributes and event listeners to the host element itself.

Fragment

Creates a virtual node that wrapps elements

ElementInternals

(Only available if formAssociated is true)

It allows to:

AsyncComponent

Asynchronously renders a component after the promise ends. In the meantime you can choose to show a load component or not show anything.

Slot

Checks if the context element has a shadow root and renders either a standard or a MichiSlot custom element, passing along attributes and children.

When nodes are added, it checks if they have a slot attribute matching the slot's name or if no name is set, appending them to the MichiSlot and triggering a slotchange event.

The main difference between the standard slot aned the MichiSlot is that the parent does not have a shadow DOM so every child appended to the parent is moved to the slot.

Custom element methods

child

Allows to get a child element from the host with the selector

idGen

Create unique IDs with a discernible key

Attributes vs Properties in jsx

Usually, if you want to get an HTML like this:

<div class='test'></div>

In React / Stencil / etc you should write a jsx like this:

() => <div className='test'></div>

And eventually code like this would be executed:

const el = document.createElement('div');
el.className = 'test';

In MichiJS you have the freedom to use both attributes and properties and the result will be the same:

// Using properties
() => <div _=></div>
// Using attributes
() => <div class='test'></div>

And eventually code like this would be executed:

const el = document.createElement('div');
// Using properties
el.className = 'test';
// Using attributes
el.setAttribute('class', 'test')

In this way the jsx syntax of MichiJS is more similar to HTML.

Lists

There are 2 ways to create a list

The static way - Using map

It's the way to create static lists from an array object. Since the result will be static, it will reflect the state of a variable when it is rendered. Useful for read-only lists.

const arrayTest = [0, 1, 2];

arrayTest.map(item => <div>{item}</div>)

This will generate an element like:

  <div>0</div>
  <div>1</div>
  <div>2</div>

The dynamic way - Using List component

It is a component that avoids using dom diff algorithms to render dynamic lists. This allows it to have a performance close to vanilla js. Operations on the array trigger corresponding changes in the DOM elements, making it ideal for dynamic lists.

const arrayTest = useObserve([0, 1, 2]);

<arrayTest.List 
  as="span"
  renderItem={item => <div>{item}</div>}
/>

This will generate an element like:

<span>
  <div>0</div>
  <div>1</div>
  <div>2</div>
</span>

Comparison

Map List component
Performance Close to vanilla
Container Virtual fragment or any other element
Data Static Dynamic
Updates No Only when an operator is triggered on the list or its elements

Routing

The custom routing tool avoids using strings to represent URLs and instead utilizes modern APIs like the URL object. It also allows separating route components, promoting cleaner code.

Example:
//Parent routes
export const [urls, Router] = registerRoutes({
  syncRoute: <div>Hello World</div>,
  //Redirect route
  '/': <Redirect to={url} />
});

//Child routes
export const [urlsChild, RouterChild] = registerRoutes({
  // Async route
  asyncChildRoute: (
    <AsyncComponent
      promise={async () => (await import('./AsyncChildExample')).AsyncChildExample}
      loadingComponent={<span>loading...</span>}
    />
  ),
  //The parent route
}, urls.syncRoute);

// Will generate this url: /sync-route/async-child-route?searchParam1=param+1&searchParam2=2#hash1
const generatedUrl = urlsChild.asyncChildRoute({ 
  searchParams: { 
    searchParam1: 'param 1', 
    searchParam2: 2
  }, 
  hash: '#hash1' 
})

Router and RouterChild are components representing the mount points for each registered route.

const AsyncChildExample: FC = () => {
    const searchParams = useSearchParams<{
      searchParam1: string, 
      searchParam2: number
    }>();
    const hash = useHash<'#hash1'| '#hash2'>();
    return (
      <>
        {/* Will show the value of searchParam1 */}
        <div>{searchParams.searchParam1}</div>
        {/* Will show true if the hash is #hash1 */}
        <div>{hash['#hash1']}</div>
      </>
    );
}

export default AsyncChildExample

I18n

Internationalization (i18n) is supported through observables. By default, the desired languages are inferred from the browser settings. If your code supports an exact match (e.g., "en-UK") or a general match (e.g., "en"), that language will be selected. Otherwise, it falls back to the default language, which is the first one in the list. The default language cannot be obtained asynchronously.

Example:

const { lang } = useStorage({
  lang: navigator.language,
});

const translator = new I18n(["en-uk", "es"], lang);

const t = translator.createTranslation({
  "en-uk": {
    dogBit: "The dog bit its owner",
    birthDay: (date: Date) => `My birthday is ${date.toLocaleDateString('en-uk')}`,
  },
  es: () => import("./translations/es.json"),
});

export const MyComponent = createCustomElement('my-component', {
  render() {
    return (
      <>
        <p>{t.dogBit}</p>
        <p>{t.birthDay(new Date(1997, 20, 2))}</p>
      </>
    );
  }
});

Limitations

Observable objects

Because some objects are not proxy compatible we limit the observable objects to:

However, we still support assignments to such complex objects in that case you will have to cast those ones with ObservableComplexObject.

  const observable = useObserve({
    file: new File([''], 'test') as unknown as ObservableComplexObject<File>
  })

This is because Typescript doesnt provide any tool to know if a type is part of the global namespace.

Polyfills

If you REALLY need polyfills i recommend you to read this topics:

Built-in elements in Safari

We provide partial support for Safari's built-in elements by emulating their behavior with a custom element, michi-generic-element. This is necessary to manage the element's lifecycle and support adoptedStyleSheets.

Browser Support

Supporting MichiJS

Sponsors

Support us with a donation and help us continue our activities here.

License