Advanced Topics

Lifecycle hooks, server-driven UI, transitions, and more

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)'
    })
  }
}
HookWhen 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

fade

Opacity

fadeScale

Fade + scale

slideUp

Slide up

slideDown

Slide down

slideLeft

From right

slideRight

From left

scale

Scale

none

Instant

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:

All 8 Presets Staggered List Animations Modal Transitions

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

Ctrl + Shift + D

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>
OptionTypeDefaultDescription
itemsArray[]Array of items to render
itemHeightnumber40Fixed height per item in pixels
containerHeightnumber400Visible container height in pixels
bufferSizenumber5Items to render above/below viewport
renderItemfunction-Function returning HTML for each item
keyFieldstring'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:

10,000+ Items Performance Stats Search/Filter Scroll Controls

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();