- Introduction
- Installation
- Core Concepts
- Reactive Objects
- Store
- DOM Composition
- Examples
- Status and Development
Mint-js is a lightweight, fine-grained reactive frontend library designed for efficient and flexible state management in web applications. It provides a simple yet powerful API for creating reactive user interfaces without the need for complex build processes or heavy framework dependencies.
To install Mint-js, run the following command in your terminal:
npm i https://github.com/chaitanya-Uike/mint-jsSignals are the core building blocks of reactivity in Mint-js. They serve as both storage containers and accessor functions for reactive values. Signals can be updated using their set method and automatically trigger updates in reactive contexts like effects and derived signals.
import { signal } from "mint-js";
const count = signal(0);
console.log(count()); // Outputs: 0
count.set(5);
console.log(count()); // Outputs: 5Signals can be derived from other signals, creating a dependency chain:
const a = signal(1);
const b = signal(() => a() * 2); // b is now dependent on a
console.log(b()); // Outputs: 2
a.set((prevVal) => prevVal + 4); // Updating 'a' using a callback
console.log(b()); // Outputs: 10Effects are functions that automatically re-run when their dependent signals change. They're perfect for handling side effects in response to state changes.
import { effect } from "mint-js";
const count = signal(0);
effect(() => {
console.log(`The count is now ${count()}`);
});
// The effect runs immediately, logging: "The count is now 0"
count.set(5); // This will trigger the effect, logging: "The count is now 5"effects run immediately on initialization, further executions are scheduled on the microtask queue for async execution.
Effects can return a cleanup function that runs before each re-execution and when the effect is disposed:
const visible = signal(true);
effect(() => {
if (visible()) {
const timer = setInterval(() => {
console.log("Tick");
}, 1000);
return () => clearInterval(timer);
// This cleanup function will be called when visible becomes false
// or when the effect is re-run or disposed
}
});For more granular control over cleanup operations, you can use the onCleanup function within effects:
import { onCleanup } from "mint-js";
effect(() => {
const resource = acquireExpensiveResource();
onCleanup(() => {
releaseExpensiveResource(resource);
});
// Use the resource here
});After the first execution, effects are executed asynchronously by default. To immediately execute all scheduled effects, use the flush function:
import { flush } from "mint-js";
const temperature = signal(20);
effect(() => {
console.log(`Current temperature: ${temperature()}°C`);
});
temperature.set(25);
flush(); // Immediately logs: "Current temperature: 25°C"Roots provide a way to manage the lifecycle of signals and effects, preventing memory leaks and allowing for controlled disposal of reactive resources.
import { createRoot } from "mint-js";
createRoot((dispose) => {
const counter = signal(0);
const doubleCounter = signal(() => counter() * 2);
effect(() => {
console.log(`Counter: ${counter()}, Double: ${doubleCounter()}`);
});
// Some time later...
dispose(); // Cleans up all signals and effects created within this root
});Roots can be nested, with child roots being disposed when their parent root is disposed:
createRoot((disposeParent) => {
const parentSignal = signal("parent");
createRoot((disposeChild) => {
const childSignal = signal("child");
effect(() => {
console.log(`${parentSignal()} - ${childSignal()}`);
});
// disposeChild(); // Would dispose only the child root
});
disposeParent(); // Disposes both parent and child roots
});The unTrack function allows you to access signal values without creating dependencies:
import { unTrack } from "mint-js";
const a = signal(1);
const b = signal(5);
effect(() => {
const result = unTrack(() => {
return a() * b(); // This computation won't be tracked by the effect
});
console.log(`Untracked result: ${result}`);
});
// Changing 'a' or 'b' won't trigger the effect
a.set(2);
b.set(10);In Mint-js, when objects or arrays are used as signals, they become reactive as a whole unit. However, their individual properties or elements don't automatically become reactive. This behavior can sometimes lead to unexpected results.
const user = signal({
name: "John",
age: 18,
});
effect(() => {
console.log(`Name is ${user().name}`);
});
user.set((prev) => ({ ...prev, age: 20 })); // Update age
flush();
// This will log 'Name is John' even though we updated ageTo achieve more granular reactivity, it's recommended to create fine-grained reactive objects by wrapping individual properties in signals (use store for this):
const user = {
name: signal("John"),
age: signal(18),
};
effect(() => {
console.log(`Name is ${user.name()}`);
});
user.age.set(20); // Update age
// This won't trigger the above effect
user.name.set("John Doe");
flush();
// This will log 'Name is John Doe'While this approach provides better granularity, it can become tedious for larger objects and make managing the disposal of signals challenging.
To address the limitations of manual fine-grained reactivity, Mint-js provides the store function. It creates an object that automatically wraps all its properties in signals, making them reactive individually.
import { store } from "mint-js";
const user = store({
name: "John",
age: 18,
});
effect(() => {
console.log(`Name is ${user.name}`); // Properties can be accessed normally
});
// Logs 'Name is John'
user.age = 20; // Properties can be updated normally without calling set
flush();
// Won't trigger the above effect
user.name = "John Doe";
flush();
// Will log 'Name is John Doe'- Lazy Wrapping: Stores only wrap properties in signals when they are accessed, avoiding unnecessary signal creation.
- Automatic Lifecycle Management: Stores handle the lifecycle of created signals internally, simplifying memory management.
- Nested Reactivity: Stores can be nested while maintaining fine-grained reactivity.
const obj = store({
user: { name: "John", age: 18 },
enabled: true,
});
effect(() => {
console.log(`Name is ${obj.user.name}`);
});
flush();
// Logs 'Name is John'
obj.user.name = "John Doe";
flush();
// Logs 'Name is John Doe'
obj.user = { name: "Sam", age: 40 }; // Complete reassignment of objects also works
flush();
// Logs 'Name is Sam'Stores can also include signals and derived signals as properties:
const baseValue = signal(5);
const obj = store({
doubleValue: signal(() => baseValue() * 2),
});
effect(() => {
console.log("Double value:", obj.doubleValue);
});
flush();
// Logs:
// Double value: 10
baseValue.set(10);
flush();
// Logs:
// Double value: 20Arrays can also be used as stores, providing reactive behavior for array operations:
const todos = store([
{ id: Date.now(), text: "Buy groceries", completed: false },
]);
effect(() => {
console.log(`First todo: '${todos[0].text}'`);
});
// Logs "First todo: 'Buy groceries'"
todos[0].text = "Buy groceries in the morning";
flush();
// Logs "First todo: 'Buy groceries in the morning'"
effect(() => {
console.log(
"All todos:",
todos.map((todo) => todo.text)
);
});
flush();
// Logs "All todos: ['Buy groceries in the morning']"
todos.push({ id: Date.now(), text: "Study physics", completed: false });
flush();
// Logs "All todos: ['Buy groceries in the morning', 'Study physics']"Properties in a store become trackable at the point where they are accessed. This behavior can lead to unexpected results if you're not careful:
const user = store({ firstName: "John", lastName: "Doe" });
function greet({ firstName, lastName }) {
effect(() => {
console.log(`Hello, ${firstName} ${lastName}!`);
});
}
greet(user);
flush();
// Logs "Hello, John Doe!"
user.firstName = "Sam";
flush();
// Won't retrigger the effect because the properties were accessed in the function arguments
// To make it work as expected, access the properties inside the effect:
effect(() => {
console.log(`Hello, ${user.firstName} ${user.lastName}!`);
});By using stores, you can create reactive objects with fine-grained reactivity while maintaining a clean and intuitive API. This approach simplifies state management in complex applications and helps avoid common pitfalls associated with manual signal creation for object properties.
Mint-js provides a powerful and intuitive way to create and manipulate DOM elements using the html tagged template function. This approach offers JSX-like features without any build-time dependencies, enabling easy creation of dynamic and reactive user interfaces.
The html tagged template function creates DOM elements using a syntax that closely resembles HTML:
import { html } from "mint-js";
const div = html`<div>Hello World!</div>`;
document.getElementById("app").appendChild(div);This creates an actual DOM element that can be directly appended to the document.
For an enhanced development experience, use the lit-html extension for Visual Studio Code. It provides syntax highlighting for html tagged template literals:
The html function seamlessly incorporates dynamic content using ${} notation:
import { html, signal } from "mint-js";
const name = signal("Alice");
const greeting = html`<h1>Hello, ${name}!</h1>`;
document.getElementById("app").appendChild(greeting);
// Updating the signal automatically updates the DOM
name.set("Bob");heres a simple timer example
import { html, signal } from "mint-js";
const time = signal(0);
setInterval(() => time.set((t) => t + 1), 1000);
document.getElementById("app").appendChild(html`<h1>time: ${time}</h1>`);Both attributes and properties can be set dynamically:
const isDisabled = signal(false);
const buttonText = signal("Click me");
const button = html`
<button disabled=${isDisabled} onClick=${() => console.log("Clicked!")}>
${buttonText}
</button>
`;
// Later updates automatically reflect in the DOM
isDisabled.set(true);
buttonText.set("Try again");When using signals in template literals, you can reference them directly without calling them as functions. Mint-js automatically unwraps signals in these contexts.
Example:
const name = signal("Alice");
html`<h1>Hello, ${name}</h1>`; // Correct: Signal is used directlyWhen using properties from a store, you must wrap them in a function (usually an arrow function) to create a reactive binding. This tells Mint-js to track changes to that specific property.
Example:
const user = store({ name: "Alice" });
html`<h1>Hello, ${() => user.name}</h1>`; // Correct: Store property is wrapped in a function- Signals:
${signalName}(no function call needed) - Store properties:
${() => storeName.propertyName}(function wrapper required)
- For signals: Both
${signalName}and${() => signalName()}will work reactively. - For stores: Only
${() => storeName.propertyName}ensures reactivity. Using${storeName.propertyName}directly will not track changes.
To maintain consistency and avoid errors, it's recommended to always use arrow functions when accessing store properties in templates, even if you're also using signals in the same template.
Note: This behavior applies specifically to how signals and stores are used within Mint-js template literals (the html tagged template function). In other contexts, such as within regular JavaScript code, you would still need to call signals as functions to get their current value.
Create reactive contexts inside the html function using arrow functions:
const show = signal(false);
html`<div style=${{ color: () => (show() ? "red" : "blue") }}>
Hello World!
</div>`;
show.set(true); // Updates the div's colorDynamic class names:
html`
<h1
className=${() => `text-3xl ${show() ? "text-red-500" : "text-blue-500"}`}
>
Hello, ${name}!
</h1>
`;Use functions for reactive conditional rendering:
const isLoggedIn = signal(false);
const username = signal("");
const loginStatus = html`
${() =>
isLoggedIn()
? html`<p>Welcome, ${username}!</p>`
: html`<p>Please log in.</p>`}
`;
// Later updates change the rendered content
isLoggedIn.set(true);
username.set("Alice");Handle events easily:
const counter = signal(0);
const counterButton = html`
<button onClick=${() => counter.set((c) => c + 1)}>Clicks: ${counter}</button>
`;Create controlled inputs with ease:
const name = signal("");
html`<input value=${name} onInput=${(e) => name.set(e.target.value)} />`;Modularize your code by creating components. Components are functions that return DOM nodes and can have their own internal state:
function Counter() {
const count = signal(0);
return html`<div>
<button onClick=${() => count.set((c) => c - 1)}>-</button>
${count}
<button onClick=${() => count.set((c) => c + 1)}>+</button>
</div>`;
}
function App() {
return html`<div><${Counter} /></div>`;
}Pass props to components:
function Greet({ firstName, lastName }) {
const fullName = signal(() => `${firstName()} ${lastName()}`);
return html`<h1>Hello! ${fullName}</h1>`;
}
function App() {
const firstName = signal("John");
const lastName = signal("Doe");
return html`<div>
<${Greet} firstName=${firstName} lastName=${lastName} />
</div>`;
}Use the spread syntax for easier prop passing:
function App() {
const firstName = signal("John");
const lastName = signal("Doe");
return html`<div><${Greet} ...${{ firstName, lastName }} /></div>`;
}The children prop is an array of child components:
function App({ children }) {
return html`<div>${children}</div>`;
}
document.getElementById("app").appendChild(html`
<${App}>
<${Header} />
<${Main} />
<${Footer} />
<//>
`);Note: You can close a component tag using <//> and </${CompName}>.
Mint-js provides powerful capabilities for rendering arrays of children and optimizing list rendering with the reactiveMap function.
You can render an array of elements directly within the html template:
const items = ["Apple", "Banana", "Cherry"];
const list = html`
<ul>
${items.map((item) => html`<li>${item}</li>`)}
</ul>
`;The reactiveMap function provides an optimized way to render reactive lists. It efficiently updates only the parts of the list that have changed, rather than re-rendering the entire list. Importantly, reactiveMap uses referential equality to track changes, eliminating the need for explicit keys.
import { html, store, reactiveMap } from "mint-js";
const todos = store([
{ id: 1, text: "Learn Mint-js", completed: false },
{ id: 2, text: "Build an app", completed: false },
]);
function TodoList() {
return html`
<ul>
${reactiveMap(
todos,
(todo) => html`
<li>
<input
type="checkbox"
checked=${() => todo.completed}
onChange=${() => (todo.completed = !todo.completed)}
/>
${todo.text}
</li>
`
)}
</ul>
`;
}Key points about reactiveMap:
- The first argument is the store or signal containing the array.
- The second argument is a mapping function that returns the
htmltemplate for each item. reactiveMapuses referential equality to track changes, so you don't need to provide explicit keys.- It efficiently updates only the parts of the list that have changed.
While signal arrays can be used for reactive lists, it's generally recommended to use store arrays instead. Store arrays provide fine-grained reactivity, which can lead to better performance and more predictable behavior in complex applications.
Benefits of using store arrays:
- Fine-grained reactivity: Only the specific elements that change will trigger updates.
- Direct mutations: You can directly modify properties of array elements without creating new objects.
- Improved performance: Especially noticeable in large lists or complex data structures.
With store arrays, you can easily modify the list and the UI will update accordingly:
const todos = store([
{ id: 1, text: "Learn Mint-js", completed: false },
{ id: 2, text: "Build an app", completed: false },
]);
function addTodo(text) {
todos.push({ id: Date.now(), text, completed: false });
}
function removeTodo(id) {
const index = todos.findIndex((todo) => todo.id === id);
if (index !== -1) todos.splice(index, 1);
}
function TodoList() {
return html`
<ul>
${reactiveMap(
todos,
(todo) => html`
<li>
<input
type="checkbox"
checked=${() => todo.completed}
onChange=${() => (todo.completed = !todo.completed)}
/>
${todo.text}
</li>
`
)}
</ul>
`;
}reactiveMap can also handle nested reactive lists efficiently:
const categories = store([
{ id: 1, name: "Fruits", items: ["Apple", "Banana", "Cherry"] },
{ id: 2, name: "Vegetables", items: ["Carrot", "Broccoli", "Spinach"] },
]);
function CategoryList() {
return html`
<div>
${reactiveMap(
categories,
(category) => html`
<div>
<h2>${category.name}</h2>
<ul>
${reactiveMap(
() => category.items,
(item) => html` <li>${item}</li> `
)}
</ul>
</div>
`
)}
</div>
`;
}In this example, both the categories and their items are reactively rendered without the need for explicit keys, benefiting from the fine-grained reactivity of stores.
When working with large lists, consider the following tips:
- Use store arrays for optimal fine-grained reactivity.
- Utilize
reactiveMapfor efficient rendering of dynamic lists. - Leverage the referential equality feature of
reactiveMapfor efficient updates. - For very large lists, consider implementing pagination or virtual scrolling to render only visible items.
- Avoid unnecessary re-renders by using memoization techniques for complex item rendering functions if needed.
By leveraging store arrays and reactiveMap for reactive lists, you can create efficient and performant list-based UIs in Mint-js without the need for explicit key management, while benefiting from fine-grained reactivity.
To help solidify your understanding of Mint-js and its features, here are some complete example components that demonstrate various aspects of the library.
This example showcases the use of stores, signals, and derived signals in a interactive game component.
import { html, store, signal } from "mint-js";
type Player = "X" | "O" | null;
type Board = Player[];
export default function TicTacToe() {
const board = store<Board>(Array(9).fill(null));
const currentPlayer = signal<Player>("X");
const gameStatus = signal<"playing" | "won" | "draw">("playing");
const winningCombos = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
const winner = signal(() => {
for (let combo of winningCombos) {
const [a, b, c] = combo;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
return null;
});
const isDraw = signal(
() => board.every((cell) => cell !== null) && !winner()
);
const gameMessage = signal(() => {
if (winner()) return `Player ${winner()} wins!`;
if (isDraw()) return "It's a draw!";
return `Current player: ${currentPlayer()}`;
});
function handleCellClick(index: number) {
if (board[index] || gameStatus() !== "playing") return;
board[index] = currentPlayer();
if (winner()) {
gameStatus.set("won");
} else if (isDraw()) {
gameStatus.set("draw");
} else {
currentPlayer.set(currentPlayer() === "X" ? "O" : "X");
}
}
function resetGame() {
for (let i = 0; i < 9; i++) {
board[i] = null;
}
currentPlayer.set("X");
gameStatus.set("playing");
}
return html`
<div
class="flex flex-col items-center justify-center min-h-screen bg-gray-100"
>
<h1 class="text-4xl font-bold mb-8 text-gray-800">Tic Tac Toe</h1>
<div class="grid grid-cols-3 gap-2 mb-4">
${Array(9)
.fill(null)
.map(
(_, index) => html`
<button
onClick=${() => handleCellClick(index)}
class="w-20 h-20 bg-white text-4xl font-bold flex items-center justify-center border-2 border-gray-300 rounded-lg hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
${() => board[index]}
</button>
`
)}
</div>
<div class="text-2xl font-semibold mb-4 text-gray-700">
${gameMessage}
</div>
<button
onClick=${resetGame}
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
>
Reset Game
</button>
</div>
`;
}This example demonstrates:
- Use of
storefor the game board - Use of
signalfor game state - Derived signals for winner and draw conditions
- Event handling with
onClick - Conditional rendering based on game state
This example shows how to create a todo list with filtering capabilities, demonstrating the use of stores, signals, and reactive rendering.
import { html, store, signal, reactiveMap } from "mint-js";
type Todo = { id: number; text: string; completed: boolean };
export default function TodoList() {
const todos = store<Todo[]>([]);
const filter = signal<"all" | "active" | "completed">("all");
const filteredTodos = signal(() => {
switch (filter()) {
case "active":
return todos.filter((todo) => !todo.completed);
case "completed":
return todos.filter((todo) => todo.completed);
default:
return todos;
}
});
function addTodo(text: string) {
todos.push({ id: Date.now(), text, completed: false });
}
function toggleTodo(id: number) {
const todo = todos.find((t) => t.id === id);
if (todo) todo.completed = !todo.completed;
}
function removeTodo(id: number) {
const index = todos.findIndex((t) => t.id === id);
if (index !== -1) todos.splice(index, 1);
}
return html`
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-xl">
<h1 class="text-2xl font-bold mb-4">Todo List</h1>
<input
type="text"
placeholder="Add new todo"
onKeyPress=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
addTodo((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = "";
}
}}
class="w-full p-2 border rounded mb-4"
/>
<div class="mb-4">
<button
onClick=${() => filter.set("all")}
class="mr-2 px-2 py-1 bg-blue-500 text-white rounded"
>
All
</button>
<button
onClick=${() => filter.set("active")}
class="mr-2 px-2 py-1 bg-green-500 text-white rounded"
>
Active
</button>
<button
onClick=${() => filter.set("completed")}
class="px-2 py-1 bg-red-500 text-white rounded"
>
Completed
</button>
</div>
<ul>
${reactiveMap(
filteredTodos,
(todo) => html`
<li class="flex items-center justify-between mb-2">
<span
class=${() =>
todo.completed ? "line-through text-gray-500" : ""}
>
${todo.text}
</span>
<div>
<button
onClick=${() => toggleTodo(todo.id)}
class="mr-2 px-2 py-1 bg-yellow-500 text-white rounded"
>
Toggle
</button>
<button
onClick=${() => removeTodo(todo.id)}
class="px-2 py-1 bg-red-500 text-white rounded"
>
Delete
</button>
</div>
</li>
`
)}
</ul>
</div>
`;
}This example demonstrates:
- Use of
storefor the todo list - Use of
signalfor filtering - Derived signal for filtered todos
- Event handling for adding, toggling, and removing todos
- Use of
reactiveMapfor efficient list rendering
This example shows how to create a simple counter that persists its state in local storage, demonstrating the use of effects and signals.
import { html, signal, effect } from "mint-js";
export default function PersistentCounter() {
const count = signal(parseInt(localStorage.getItem("count") || "0"));
effect(() => {
localStorage.setItem("count", count().toString());
});
return html`
<div class="flex flex-col items-center justify-center h-screen bg-gray-100">
<h1 class="text-4xl font-bold mb-4">Persistent Counter</h1>
<p class="text-2xl mb-4">Count: ${count}</p>
<div>
<button
onClick=${() => count.set((c) => c - 1)}
class="px-4 py-2 bg-red-500 text-white rounded mr-2"
>
Decrease
</button>
<button
onClick=${() => count.set((c) => c + 1)}
class="px-4 py-2 bg-green-500 text-white rounded"
>
Increase
</button>
</div>
</div>
`;
}This example demonstrates:
- Use of
signalfor state management - Use of
effectfor side effects (local storage persistence) - Event handling for increasing and decreasing the counter
These examples showcase different aspects of Mint-js, including state management with stores and signals, derived signals, reactive rendering, event handling, and side effects. They provide a practical demonstration of how these concepts come together to create interactive and efficient web applications.
Mint-js is currently under active development. While the library is evolving, the core API mentioned in the doc will largely remain the same. Mint-js serves as an excellent tool for learning about reactive programming and modern frontend development