Writing fast PraxisJS
Practical rules for fast PraxisJS apps — arrow-function bindings, For and Index, @Selector, @Computed, keeping work out of bindings, and finding slow parts with the DevTools profiler and @Trace.
Writing fast PraxisJS
Most PraxisJS code is fast without effort, because updates already go straight to the DOM nodes involved. The rules below cover the cases where your code decides how much work an update does.
Bind only what changes
An arrow function in JSX creates a binding. A plain expression is read once at render time and costs nothing afterwards.
<td>{row.id}</td> {/* never changes: static, no binding */}
<td>{() => row.label}</td> {/* changes: one binding, updated in place */}Wrap a value in an arrow only when it can change while the component is mounted. Keep bindings narrow: {() => this.user.name} on the one node that shows it, rather than one arrow that rebuilds a whole block.
Render lists with For or Index
<For each={() => this.todos} by="id">
{(todo) => <li>{() => todo().title}</li>}
</For>Forkeeps rows by key. Give it a stableby(an id, not the index). Rows are created once, moved when the order changes and removed when their key goes away.Indexkeeps rows by position. Use it for primitives, lists with repeated values and inputs edited in place.- A keyed
.map()inside an arrow is rewritten toForby the Vite plugin, but writingFordirectly is better: its row function runs once, while the rewritten form re-renders a row whenever anything it reads changes.
To update one row, either replace its object under the same key (bindings reading item() update, the row stays), or make rows stores with @State fields and write the field (row.label = '…'), which updates only that field's bindings.
See Lists.
Use @Selector for "one of many"
When many rows compare themselves to one value (the selected id, the active tab, the hovered item), use @Selector:
@State() selected = 0
@Selector('selected') isSelected!: Selector<number>
// in the row
<tr class={() => (this.isSelected(row().id) ? 'active' : '')}>A change then updates two rows instead of all of them.
Keep work out of bindings
A binding re-runs every time a signal it reads changes. Anything expensive inside it runs that often too.
- Derive with
@Computed. A filtered, sorted or summed value belongs in a@Computed()getter: it is cached and recomputes only when its sources change. A plain getter recomputes on every read. - Don't build lists inside a binding.
{() => this.items.filter(...).map(...)}rebuilds every row on each change. Put the filter in a@Computedand render it withFor. - Keep
render()and row functions plain. They run once, so a slow one slows down every mount. Fetch data in@ResourceoronMount, not inrender(). - Read without subscribing when you only need the current value, with
peek()oruntrack()from@praxisjs/core. See Reading without tracking.
Write state in one go
Writes inside a JSX event handler are batched: effects run once when the handler returns. Outside handlers (timers, sockets, promise callbacks), group related writes with batch() from @praxisjs/core so the DOM updates once.
Replace arrays and objects with new references (this.items = [...this.items, item]). Prefer @State with immutable updates over @DeepState on hot paths: any nested change to a @DeepState field re-runs everything that reads it.
Render less
- Long lists: render only the visible window with
VirtualList. - Below the fold: defer heavy components with
@Lazyuntil they scroll into view.
Find the slow parts
Measure before changing code.
- DevTools Performance panel. Press Record, use the app, then Stop. It lists signal writes, effect runs and computed recomputations, and ranks components by how much work their bindings did. See DevTools.
@Trace()on a component shows its render count and render time in the Components panel.@Trace({ slow: 4 })warns in the console when a render takes more than 4 ms. See@Trace.- The browser profiler (Chrome DevTools → Performance) shows whether time goes to JavaScript or to layout and paint. If it is layout and paint, the fix is less DOM, not faster code.
@Trace({ slow: 4 })
@Component()
class DataGrid extends StatefulComponent { /* ... */ }The profiler is also available from code and from the console:
const { profiler } = DevTools.registry // or __PRAXIS_DEVTOOLS__.profiler in the console
profiler.start()
// … interact …
profiler.stop()
console.table(profiler.snapshot().owners)@Debug and @Trace cost nothing in a production build: they return the class or member unchanged.
Measure production builds
Development builds include warnings and DevTools hooks. Compare performance with vite build and vite preview, not with the dev server.