@michijs/michijs
v2.1.5
Published
Typescript library to build web components
Downloads
965
Maintainers
Readme
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
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:
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:
- Any update on the store will trigger an update on a component, even if the property that changed in the store has no relation to the component. Every tag, attribute, etc., will need to be checked for changes in every re-render.
- Any update on a component will trigger an update on the children, which might be unnecessary.
- There is no way to set static properties in a dynamic environment. Take this React example:
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.
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:
- Can be used in various contexts, including top-level script code, functional components, and custom hooks. This flexibility allows developers to encapsulate logic and state management using hooks in different parts of their application.
- Most of them return observables.
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 one argument:
- item: The value to be observed.
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.
useObservePrimitive
Unproxied version of useObserve. Similar to tc39 signals proposal. Takes one argument:
- item: The value to be observed.
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:
- callback: A function that returns a value of type T.
- deps: An array of dependencies that the callback function depends on.
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:
- callback: A function that returns a promise of type T.
- initialValue: Initial value of type T.
- deps: Dependencies to watch for changes.
- options: Optional object that may contain
onBeforeUpdate
andonAfterUpdate
callback functions.
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:
- callback: A function that returns a value of type T.
- deps: Dependencies to watch for changes.
- options: Optional object that may contain
onBeforeUpdate
andonAfterUpdate
callback functions.
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
useComputedObservePrimitive
Unproxied version of useComputedObserve. Similar to tc39 signals proposal. Takes three arguments:
- callback: A function that returns a value of type T.
- deps: Dependencies to watch for changes.
- options: Optional object that may contain
onBeforeUpdate
andonAfterUpdate
callback functions.
useStringTemplate
It is used to create a string template by interpolating dynamic values.
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:
- callback: A function that returns a value of type T. This is the function that will be invoked when any dependency changes.
- deps: Optional array of dependencies to watch for changes.
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:
- callback: A function that returns the request options.
- shouldWait: An optional array of promises that should resolve before executing the fetch.
- options: Additional options for the fetch operation.
Returns: An object of type FetchResult<R>
, which includes:
- promise: An observable representing the fetch promise.
- recall(): A method to call the promise again, available after the first call.
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:
- callback: The operation that returns a promise.
- shouldWait: An optional array of promises that should resolve before executing the promise.
Returns: A PromiseResult
object, which includes:
- promise: An observable representing the promise.
- recall(): A method to call the promise again, available after the first call.
You can also use
doPromise
for an imperative alternative.
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.
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
.
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
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:
- item: The object to be observed and synchronized with storage.
- storage: (Optional) The storage object to be used, defaults to
localStorage
.
If you want to use cookies we provide a class that acts like an storage called CookieStorage
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:
- name: Specifies the name of the IndexedDB database to be used or created.
- objectsStore: Is a generic type that describes the structure of the object stores. It's defined as an object where each key represents the name of a property in the stored objects, and the value represents the configuration options for that property.
- version: (Optional) specifies the version number of the IndexedDB database. If the database with the specified name already exists and its version is lower than the provided version, it will perform any necessary upgrades.
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.
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.
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.
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.
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:
- Make the element accessible to the browser
- Access element internals
- Validate and assign values to forms
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 _={{className: 'test'}}></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
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.
//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.
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:
- Arrays
- Dates
- Maps
- Sets
- Any object whose prototype is Object
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:
- https://www.webcomponents.org/polyfills
- https://ungap.github.io
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
- Customized built-in elements: Chrome feature status
- Autonomous custom elements: Chrome feature status WebComponents.org
- Framework Compatibility: Custom Elements Everywhere
- Element internals: MDN Documentation
Supporting MichiJS
Sponsors
Support us with a donation and help us continue our activities here.