Skip to content

Getting Started

use-legend is a collection of observable-native React utility hooks inspired by VueUse and react-use. While those libraries target Vue’s reactivity system and React’s useState/useEffect respectively, use-legend is built from the ground up for Legend-State observables — delivering fine-grained reactivity without whole-component re-renders.

Terminal window
# Web + required peer deps
npm install @usels/web@beta @legendapp/state react
# Auto Memo transform plugin (recommended)
npm install -D @usels/vite-plugin-legend-memo

use-legend hooks don’t use useState internally. Instead, they return Legend-State observables — fine-grained reactive values that update without re-rendering the entire component tree.


Legend-State’s <Memo> subscribes to its function child with fine-grained reactivity. Writing this wrapper by hand is repetitive — the plugin automates it at build time.

// Without the plugin — manual wrapping required
<button>
<Memo>{() => count$.get()}</Memo> times
</button>
// With the plugin — write count$.get() as-is
<button>
{count$.get()} times {/* compiled to the same output above */}
</button>
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { autoWrap } from '@usels/vite-plugin-legend-memo';
export default defineConfig({
plugins: [
autoWrap(), // must come before react()
react(),
],
});

autoWrap() runs with enforce: "pre", so it transforms JSX before @vitejs/plugin-react’s esbuild pass.

Terminal window
npm install -D @usels/babel-plugin-legend-memo
babel.config.js
module.exports = {
plugins: ['@usels/babel-plugin-legend-memo'],
};
ExpressionTransformedReason
count$.get()$-suffixed variable, no arguments
user$.name.get()nested paths are detected
obs$?.get()optional chaining supported
list$.get(0)has arguments (key access)
count.get()no $ suffix
.get() inside observeralready inside a reactive context

Set allGet: true to detect every .get() call regardless of the $ suffix.


The examples below assume @usels/vite-plugin-legend-memo is configured. Every count$.get() expression is automatically compiled into <Memo>{() => count$.get()}</Memo> — only that expression re-renders when the observable changes, not the parent component.

The foundation of use-ls. useRef$ works like React’s useRef but returns a Ref$ — an observable that any use-ls hook can react to automatically.

import { useRef$, useEventListener } from '@usels/web';
import { observable } from '@legendapp/state';
function ClickCounter() {
const button$ = useRef$<HTMLButtonElement>();
const count$ = observable(0);
useEventListener(button$, 'click', () => {
count$.set(c => c + 1);
});
return (
<button ref={button$}>
Clicked {count$.get()} times
</button>
);
}

When button$ mounts or is replaced, useEventListener re-registers automatically. count$ is managed as a Legend-State observable — no useState needed.


Tracks an element’s dimensions as an observable. No manual ResizeObserver setup required.

import { useRef$, useElementSize } from '@usels/web';
function SizeDisplay() {
const el$ = useRef$<HTMLDivElement>();
const size$ = useElementSize(el$);
return (
<div ref={el$} style={{ resize: 'both', overflow: 'auto', padding: 16 }}>
{`${size$.width.get().toFixed(0)} × ${size$.height.get().toFixed(0)}`}
</div>
);
}

size$.width and size$.height update whenever the element resizes. Only the expression that reads the observable re-renders.


Tracks an element’s scroll position as an observable.

import { useRef$, useScroll } from '@usels/web';
function ScrollTracker() {
const container$ = useRef$<HTMLDivElement>();
const scroll$ = useScroll(container$);
return (
<div ref={container$} style={{ height: 300, overflowY: 'scroll' }}>
<div style={{ height: 1000, paddingTop: 16 }}>
{`scrollY: ${scroll$.y.get().toFixed(0)}px`}
</div>
</div>
);
}

To track the entire window’s scroll position, use useWindowScroll() instead.


Returns a CSS media query result as an observable boolean. Breakpoint logic can be lifted out of components into shared observables.

import { useMediaQuery } from '@usels/web';
function Layout() {
const isMobile$ = useMediaQuery('(max-width: 768px)');
return (
{isMobile$.get() ? <MobileNav /> : <DesktopNav />}
);
}