Lifecycle Hooks
Nounjs components have lifecycle methods that run at specific points.
class UserList extends Component {
users = []
// Called after component is mounted to DOM
async mounted() {
console.log('Mounted to:', this.$el)
console.log('Refs available:', this.$refs)
// Fetch initial data - reactivity auto-updates the DOM
this.users = await api.getUsers()
// Add global listeners
window.addEventListener('resize', this.handleResize)
}
// Called after state updates and re-render
updated() {
// Scroll to bottom when items change
this.$refs.list.scrollTop = this.$refs.list.scrollHeight
}
// Called before component is destroyed
unmounted() {
// Cleanup
this.player?.destroy()
window.removeEventListener('resize', this.handleResize)
}
// Called when a new element is inserted in a :for loop
oninsert(el, item, index) {
// Animate new items
el.style.opacity = '0'
el.style.transform = 'translateY(-10px)'
requestAnimationFrame(() => {
el.style.transition = 'all 0.3s ease'
el.style.opacity = '1'
el.style.transform = 'translateY(0)'
})
}
}
| Hook | When Called |
|---|---|
mounted() | After component is mounted to DOM, $refs are available |
updated() | After state updates and re-render |
unmounted() | Before component is destroyed |
oninsert(el, item, index) | When a new element is inserted in a :for loop |
New in v0.9.31: Async mounted() Error Handling
Async mounted() errors are now automatically caught by the framework. You should still handle errors explicitly if you want to show error states to users.
async mounted() {
// Errors are now caught automatically!
const data = await fetch('/api/data')
this.items = await data.json()
}
Server-Driven UI
Nounjs hydration enables server-driven UI patterns where your server returns HTML fragments that Nounjs makes interactive. Similar to HTMX or Laravel Livewire.
Not Traditional SSR
This is NOT SSR-to-SPA hydration like Next.js. Nounjs hydration is for app UIs where the server returns HTML fragments to specific containers. The server is the source of truth.
Server Returns HTML
// C# with RazorHelpers (Recommended)
app.MapGet("/api/users/table", async (UserService users) =>
{
var data = await users.GetAllAsync();
return RazorResults.Razor(
Html.Table(data)
.Column("Name", u => u.Name)
.Column("Actions", u => Html.Button("Delete")
.Attr("@click", $"deleteUser({u.Id})"))
);
});
Client Fetches & Hydrates
class UserTable extends Component {
async loadTable() {
const html = await fetch('/api/users/table')
.then(r => r.text())
this.$el.innerHTML = html
this.hydrate(this.$el)
}
async deleteUser(id) {
await fetch(`/api/users/${id}`, { method: 'DELETE' })
await this.loadTable() // Refresh from server
}
}
What Gets Hydrated
- Yes: Event handlers (@click, @submit), Dynamic bindings (:class, :style), Element refs
- No: Text interpolation, :if/:for directives (server already rendered these)
When data changes, fetch fresh HTML from the server. This keeps client/server in sync.
Ideal Use Cases
Admin Dashboards
Internal Tools
Apps Behind Auth
Data-Heavy UIs
Transitions
Lightweight enter/leave animations using Tailwind CSS classes.
import { transitionEnter, transitionLeave } from '@noundryfx/nounjs'
// Fade in an element
el.style.display = 'block'
await transitionEnter(el, 'fade')
// Fade out
await transitionLeave(el, 'fade')
el.style.display = 'none'
// Custom transition
await transitionEnter(el, {
enterFrom: 'opacity-0 -translate-y-4',
enterActive: 'transition-all duration-300 ease-out',
enterTo: 'opacity-100 translate-y-0'
})
Preset Transitions
fadeOpacity
fadeScaleFade + scale
slideUpSlide up
slideDownSlide down
slideLeftFrom right
slideRightFrom left
scaleScale
noneInstant
Modal Example
class Modal extends Component {
isOpen = false
async open() {
const { backdrop, content } = this.$refs
backdrop.style.display = 'block'
content.style.display = 'block'
await Promise.all([
transitionEnter(backdrop, 'fade'),
transitionEnter(content, 'fadeScale')
])
this.isOpen = true
}
async close() {
const { backdrop, content } = this.$refs
await Promise.all([
transitionLeave(backdrop, 'fade'),
transitionLeave(content, 'fadeScale')
])
backdrop.style.display = 'none'
content.style.display = 'none'
this.isOpen = false
}
}
See it in Action: Nounjs Starter
The Starter app includes a complete transitions demo at /transitions with:
See src/pages/transitions.noun for the complete implementation.
Reactivity System
Understanding how Nounjs tracks and updates state.
New in v0.9.31: Deep Reactivity
Nested object mutations now trigger updates automatically! No more manual spreading.
// All of these now auto-update the DOM:
this.user.name = 'Jane' // ✅ Works!
this.user.address.city = 'NYC' // ✅ Works!
this.settings.theme.dark = true // ✅ Works!
class Counter extends Component {
// These become reactive properties
count = 0
name = 'Counter'
items = []
user = { name: 'John' }
increment() {
// Direct assignment - reactivity auto-updates the DOM
this.count = 5
// Or batch multiple updates with update()
this.update({ count: 5, name: 'New Name' })
}
addItem() {
// Array mutations auto-trigger update
this.items.push({ id: 1 })
this.items.splice(0, 1)
this.items.remove(item) // Nounjs extension
}
}
// Computed properties with getters
class Cart extends Component {
items = []
get itemCount() {
return this.items.length
}
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0)
}
}
DevTools
Nounjs includes built-in visual DevTools for debugging components, state, and events.
Enable DevTools
import { enableDevTools } from '@noundryfx/nounjs'
// Enable on localhost only
if (location.hostname === 'localhost') {
enableDevTools()
}
// Or with environment check
if (import.meta.env?.DEV) {
enableDevTools()
}
Keyboard Shortcut
Toggle DevTools panel
(Cmd+Shift+D on Mac)
Features
Component Tree
View all mounted components with state counts and binding info
State Inspector
View and edit component state in real-time
Event Log
Track mount/unmount and state change events
Console API
DevTools also exposes a console API for advanced debugging:
// List all components
__noun__.tree()
// Inspect component by ID
__noun__.inspect('noun_abc123')
// View recent events
__noun__.events()
// Get component map
__noun__.components
// Toggle panel
__noun__.show()
TypeScript Support
Nounjs supports TypeScript as a first-class citizen in .noun files and includes full type definitions.
In .noun Files
<template>
<div :for="user of users">
{ user.name } - { user.role }
</div>
</template>
<script lang="ts">
interface User {
id: number
name: string
role: 'admin' | 'user'
}
export default {
users: [] as User[],
async mounted(): Promise<void> {
this.users = await api.getUsers() // Auto-updates DOM
}
}
</script>
In Component Classes
import { Component, mount } from '@noundryfx/nounjs'
interface User {
id: number
name: string
email: string
}
class UserList extends Component {
users: User[] = []
loading: boolean = true
async mounted(): Promise<void> {
this.users = await api.getUsers()
this.loading = false // Reactivity auto-updates DOM
}
}
TypeScript Transpilation
When using <script lang="ts">, Nounjs uses Bun's native transpiler for fast compilation.
In environments without Bun, it falls back to regex-based type stripping.
VirtualList
Efficiently render large lists (10,000+ items) by only rendering visible items. Essential for log viewers, data grids, and any scrollable list with many items.
When to Use VirtualList
Use VirtualList for lists with 100+ items where performance matters. For small lists (<50 items), standard :for is simpler and works fine.
Functional API (Recommended)
import { createVirtualList } from './build/noun.esm.js'
// Create virtual list with 10,000 items
const list = createVirtualList(
document.getElementById('container'),
{
items: users, // Array of items
itemHeight: 40, // Fixed height per item
containerHeight: 400, // Visible area height
bufferSize: 5, // Extra items above/below
renderItem: (user, index) => `
<div class="p-2 border-b">
${user.name} - ${user.email}
</div>
`
}
)
// Update items dynamically
list.update(newUsers)
// Scroll programmatically
list.scrollToIndex(500) // Jump to item 500
list.scrollToTop() // Jump to first item
list.scrollToBottom() // Jump to last item
// Cleanup when done
list.destroy()
With Search/Filter
const allUsers = [...] // 10,000 users
const list = createVirtualList(container, {
items: allUsers,
itemHeight: 40,
containerHeight: 400,
renderItem: (user) => `
<div class="p-2">${user.name}</div>
`
})
// Filter on search input
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase()
const filtered = allUsers.filter(u =>
u.name.toLowerCase().includes(query)
)
list.update(filtered)
})
In a .noun Component
<!-- log-viewer.noun -->
<template>
<div>
<div class="flex gap-2 mb-4">
<input type="text" :model="search" placeholder="Filter..." class="noun-input" />
<button @click="jumpToBottom" class="noun-btn">Latest</button>
</div>
<div id="log-container"></div>
</div>
</template>
<script>
import { createVirtualList } from '../../build/noun.esm.js'
export default {
logs: [],
_list: null,
async mounted() {
this.logs = await this.props.api.getLogs()
this.initList()
},
unmounted() {
this._list?.destroy() // Cleanup
},
initList() {
const container = this.$el.querySelector('#log-container')
this._list = createVirtualList(container, {
items: this.logs,
itemHeight: 24,
containerHeight: 500,
renderItem: (log) => `
<div class="font-mono text-xs p-1 ${log.level === 'error' ? 'text-red-600' : ''}">
${log.timestamp} [${log.level}] ${log.message}
</div>
`
})
},
jumpToBottom() {
this._list?.scrollToBottom()
}
}
</script>
| Option | Type | Default | Description |
|---|---|---|---|
items | Array | [] | Array of items to render |
itemHeight | number | 40 | Fixed height per item in pixels |
containerHeight | number | 400 | Visible container height in pixels |
bufferSize | number | 5 | Items to render above/below viewport |
renderItem | function | - | Function returning HTML for each item |
keyField | string | 'id' | Property to use as unique key |
C# API Backend (Noun Tuxedo)
Feed VirtualList from a C# API using Noun Tuxedo ORM with native pagination support.
Entity Model
using Noun.Tuxedo;
[Table("Users")]
public class User
{
[Key]
public int Id { get; set; }
[Column("first_name")]
public string FirstName { get; set; } = "";
[Column("last_name")]
public string LastName { get; set; } = "";
public string Email { get; set; } = "";
[Column("is_active")]
public bool IsActive { get; set; } = true;
[Computed]
public DateTime CreatedAt { get; set; }
// Computed display property
[Write(false)]
public string FullName => $"{FirstName} {LastName}";
}
API Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IDbConnection _db;
public UsersController(IDbConnection db) => _db = db;
[HttpGet("virtual-list")]
public async Task<IActionResult> GetUsers(
[FromQuery] int pageIndex = 0,
[FromQuery] int pageSize = 50,
[FromQuery] string? search = null)
{
var sql = "SELECT * FROM Users WHERE is_active = 1";
if (!string.IsNullOrEmpty(search))
sql += " AND (first_name LIKE @Search OR email LIKE @Search)";
sql += " ORDER BY id";
// Tuxedo: Native pagination
var result = await _db.QueryPagedAsync<User>(
sql,
pageIndex: pageIndex,
pageSize: pageSize,
param: new { Search = $"%{search}%" });
return Ok(result);
}
}
Service with QueryBuilder
public class UserService
{
private readonly IDbConnection _db;
public UserService(IDbConnection db) => _db = db;
public async Task<PagedResult<User>> GetUsersAsync(
int pageIndex, int pageSize,
string? search, string? department)
{
var builder = new QueryBuilder<User>(TuxedoDialect.SqlServer)
.SelectAll()
.Where(u => u.IsActive);
if (!string.IsNullOrEmpty(search))
builder.And($"(first_name LIKE @s OR email LIKE @s)",
new { s = $"%{search}%" });
if (!string.IsNullOrEmpty(department))
builder.And(u => u.Department == department);
builder.OrderBy(u => u.LastName)
.Page(pageIndex, pageSize);
// Returns PagedResult<T> with Items, TotalCount, etc.
return await builder.ToPagedResultAsync(_db);
}
}
Frontend Integration
// Fetch from C# API
async function loadUsers(page = 0, search = '') {
const params = new URLSearchParams({
pageIndex: page,
pageSize: 50,
...(search && { search })
})
const res = await fetch(`/api/users/virtual-list?${params}`)
const data = await res.json()
// data = { items, pageIndex, pageSize, totalCount,
// totalPages, hasNextPage, hasPreviousPage }
return data.items
}
// Initialize VirtualList with API data
const users = await loadUsers()
const list = createVirtualList(container, {
items: users,
itemHeight: 60,
containerHeight: 500,
renderItem: (user) => `
<div class="p-3 border-b">
<div class="font-medium">${user.fullName}</div>
<div class="text-sm text-gray-500">${user.email}</div>
</div>
`
})
// Update on search
searchInput.oninput = async (e) => {
const users = await loadUsers(0, e.target.value)
list.update(users)
}
See it in Action: Nounjs Starter
The Starter app includes a complete VirtualList demo at /virtuallist with:
See src/pages/virtuallist.noun for the complete implementation.
Tuxedo PagedResult<T>
Tuxedo's QueryPagedAsync returns a built-in PagedResult<T> with:
- Items - Current page data
- PageIndex - Zero-based page number
- TotalCount - Total items in dataset
- TotalPages - Calculated page count
- HasNextPage / HasPreviousPage - Navigation flags
Program.cs Setup
using Noun.Tuxedo;
var builder = WebApplication.CreateBuilder(args);
// Register Tuxedo
builder.Services.AddTuxedo(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("Default")!;
options.Dialect = TuxedoDialect.SqlServer; // or PostgreSql, MySql, Sqlite
});
builder.Services.AddScoped<UserService>();
builder.Services.AddControllers();
// CORS for Nounjs frontend
builder.Services.AddCors(opts => opts.AddPolicy("Nounjs", p =>
p.WithOrigins("http://localhost:3000").AllowAnyMethod().AllowAnyHeader()));
var app = builder.Build();
app.UseCors("Nounjs");
app.MapControllers();
app.Run();