grainlet

中文文档
1.1.11发布于 2026/11/04MITTypeScriptES Module零依赖

Fine-grained signals, JSX, routing, and SSR for the web

原始使用文档
文档目录19 节
  1. Install
  2. Usage
  3. Vite
  4. Entry points
  5. Signals and lifecycle
  6. onMount
  7. batch and transitions
  8. Store
  9. Control flow
  10. Refs
  11. TypeScript
  12. Forms
  13. Auth
  14. i18n
  15. Query
  16. Routing
  17. SSR
  18. Testing
  19. Devtools

grainlet

Fine-grained reactive UI: signals, JSX, queries, forms, auth, i18n, History API routing, and SSR / hydrate.

Install

npm install grainlet

For Vite JSX (dev only — install as devDependencies):

npm install -D grainlet-vite vite @babel/core @babel/plugin-syntax-jsx

Or scaffold a project:

npx create-grainlet my-app

Usage

import { createSignal, render } from 'grainlet';

function App() {
  const [count, setCount] = createSignal(0);
  return (
    <button type="button" onClick={() => setCount((c) => c + 1)}>
      {count()}
    </button>
  );
}

render(App, document.getElementById('app'));

Vite

import { defineConfig } from 'vite';
import { grainJsx } from 'grainlet-vite';

export default defineConfig({
  plugins: [grainJsx()],
  esbuild: {
    jsx: 'automatic',
    jsxImportSource: 'grainlet',
  },
});

Entry points

ImportPurpose
grainletCore API (signals, flow, render, hydrate)
grainlet/routeHistory API routing (Router, Route, Link, …)
grainlet/formsForm state (FormProvider, Field, createForm, …) — see forms/README.md
grainlet/authSession auth (createAuth, AuthProvider, useSession, …) — see auth/README.md
grainlet/auth-sdkGoogle, Apple, and GitHub OAuth integration — see auth-sdk/README.md
grainlet/i18nTranslations (createI18n, I18nProvider, useTranslation, …) — see i18n/README.md
grainlet/queryAsync server state (QueryClient, useQuery, useMutation, …) — see query/README.md
grainlet/utilsGeneric helpers (stableHash, replaceEqualDeep, Subscribable, …) — see utils/README.md
grainlet/storeFine-grained proxy stores (createStore, produce, reconcile) — see store/README.md
grainlet/testingComponent, hook, event, wait, and hydration test helpers — see testing/README.md
grainlet/devtoolsDevelopment signal/owner and Query inspectors — see devtools/README.md
grainlet/ssrBuffered/streaming SSR, SSG, head, and Web/Node adapters — see ssr/README.md
grainlet/jsx-runtimeAutomatic JSX runtime
grainlet-vitegrainJsx() Vite plugin (separate package, devDependency)
grainlet-adaptersVercel and Cloudflare SSR deploy adapters (separate package)

Breaking: Router and SSR APIs are no longer re-exported from grainlet. Import them from grainlet/route and grainlet/ssr. Forms, auth, auth-sdk, and i18n live under their respective grainlet/* entry points only.

Signals and lifecycle

import {
  batch,
  createDeferred,
  createEffect,
  createMemo,
  createSignal,
  isServer,
  onCleanup,
  onMount,
  startTransition,
  untrack,
  useTransition,
} from 'grainlet';
APIRole
createSignal(initial)[get, set] — get() reads, set(v) or set(prev => next) writes
createMemo(fn)Cached accessor; recomputes when dependencies change
createEffect(fn)Run when dependencies change; may return cleanup. No-op on the server
onMount(fn)Once after this component's DOM is mounted; may return cleanup. No-op on the server
onCleanup(fn)Run when the current effect or component is disposed
batch(fn)Coalesce writes; flush effects once
untrack(fn)Read signals without subscribing
startTransition(fn)Schedule non-urgent updates; returns a Promise that settles after the flush
useTransition()[pending, start] — pending() is true while a transition is flushing
createDeferred(source, { timeoutMs? })Deferred copy of source, updated through startTransition
isServer()True during SSR

onMount

onMount runs after the owned DOM exists, so refs and measurements are valid. It does not re-run on later updates. Returning a function registers cleanup on unmount.

function Chat() {
  let root;

  onMount(() => {
    root.focus();
    const id = setInterval(poll, 5000);
    return () => clearInterval(id);
  });

  return <section ref={(el) => { root = el; }} tabIndex={-1}>…</section>;
}

Use onMount for one-shot DOM setup (focus, observers, subscriptions). Use createEffect when work should re-run as signals change; it may also return cleanup, or register extra teardown with onCleanup.

function Title(props) {
  createEffect(() => {
    document.title = props.title();
  });

  onCleanup(() => {
    document.title = 'Grainlet';
  });
}

batch and transitions

Default writes flush effects synchronously. batch groups several sets into one flush. startTransition keeps that flush off the urgent path so typing and clicks stay snappy while a large list catches up.

const [first, setFirst] = createSignal('');
const [last, setLast] = createSignal('');
const [filter, setFilter] = createSignal('');
const [pending, start] = useTransition();
const deferredFilter = createDeferred(filter);

createEffect(() => {
  first();
  last();
  // runs once per batch, not twice
});

batch(() => {
  setFirst('Ada');
  setLast('Lovelace');
});

function Search() {
  return (
    <>
      <input
        value={filter()}
        onInput={(e) => setFilter(e.currentTarget.value)}
      />
      <button
        type="button"
        disabled={pending()}
        onClick={() => start(() => setFilter(filter().trim()))}
      >
        Apply
      </button>
      <List filter={deferredFilter()} />
    </>
  );
}

isServer() is true inside renderToString* / streaming SSR. Skip browser APIs there; onMount and createEffect already no-op on the server.

Store

Nested objects and arrays — import from grainlet/store. Reads track individual paths; one setter call is batched.

import { createStore, produce, reconcile } from 'grainlet/store';

const [state, setState] = createStore({
  user: { name: 'Ada', age: 1 },
  todos: [{ title: 'Ship', done: false }],
});

setState('user', 'name', 'Grace');
setState({ user: { name: 'Grace', age: 2 } });
setState('todos', 0, produce((todo) => {
  todo.done = true;
}));
setState('user', reconcile({ name: 'Grace', age: 3 }));

createEffect(() => {
  state.user.name; // does not re-run when age changes
});

produce mutates a cloned draft. reconcile applies a new tree while preserving unchanged nodes. Full guide: store/README.md.

Control flow

import {
  Show,
  For,
  VirtualList,
  Switch,
  Match,
  Suspense,
  ErrorBoundary,
  Portal,
  createContext,
  useContext,
  createResource,
  lazy,
} from 'grainlet';
ComponentRole
ShowRender children when when is truthy (else fallback)
ForMap each list to children; prefers item.id keys
VirtualListWindowed list (vertical or horizontal) — overscan, optional debounceTime, infinite scroll via onEndReached
Switch / MatchFirst matching when branch
Suspensefallback while nested createResource / lazy is pending
ErrorBoundaryCatch render/update errors; fallback or (error, reset) => …
PortalRender children into document.body (or mount)
createContext / useContextShare data without prop drilling (Provider + consume)
createResourceAsync data [resource] — resource() reads value
lazyLazy-load a component (lazy(() => import('./Page.js')))

Prefer these over {cond() && <X/>} so conditionals update without re-running the parent.

<VirtualList
  each={items()}
  itemHeight={48}
  height={400}
  overscan={5}
  aria-label="Items"
>
  {(item) => <div class="row">{item.label}</div>}
</VirtualList>

// The outer scroller accepts normal div props, a DOM ref, and an imperative API.
let scroller;
let listApi;

<VirtualList
  each={items()}
  itemHeight={48}
  height={400}
  id="search-results"
  aria-label="Search results"
  data-testid="results-list"
  ref={(element) => (scroller = element)}
  apiRef={(api) => (listApi = api)}
  onScroll={(event) => console.log(event.currentTarget.scrollTop)}
>
  {(item) => <div class="row">{item.label}</div>}
</VirtualList>

listApi?.scrollToIndex(250, { align: 'center', behavior: 'smooth' });
listApi?.scrollToItem(items()[0]);
listApi?.scrollToOffset(0);
listApi?.getVisibleRange(); // { start, end }
listApi?.getElement() === scroller; // true

<VirtualList
  orientation="horizontal"
  each={items()}
  itemWidth={120}
  width={480}
  height={80}
  debounceTime={32}
  aria-label="Horizontal items"
  data-layout="horizontal"
  apiRef={(api) => (listApi = api)}
>
  {(item) => <div class="card">{item.label}</div>}
</VirtualList>

// Infinite scroll — parent owns fetch + append
const [page, setPage] = createSignal([]);
const [loading, setLoading] = createSignal(false);
const [hasMore, setHasMore] = createSignal(true);

async function loadMore() {
  if (loading() || !hasMore()) return;
  setLoading(true);
  const next = await fetchPage(page().length);
  setPage((list) => [...list, ...next.items]);
  setHasMore(next.hasMore);
  setLoading(false);
}

<VirtualList
  each={page()}
  itemHeight={88}
  height={520}
  onEndReached={loadMore}
  endReachedThreshold={0.2}
  endReachedLoading={loading()}
  aria-label="Infinite results"
  aria-busy={loading()}
  apiRef={(api) => (listApi = api)}
>
  {(item) => <div class="row">{item.label}</div>}
</VirtualList>
<ErrorBoundary
  fallback={(error, reset) => (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button type="button" onClick={reset}>Try Again</button>
    </div>
  )}
>
  <ErrorProne />
</ErrorBoundary>

Event handlers and setTimeout callbacks are not caught (same as Solid).

<div style={{ overflow: 'hidden' }}>
  <Portal>
    <div class="popup">Escapes overflow clipping</div>
  </Portal>
</div>

<Portal mount={document.querySelector('#portal-root')}>
  <p>Custom mount node</p>
</Portal>

{/* SVG: wrap in <g>; head: no wrapper */}
<Portal mount={svgEl} isSVG>
  <circle r="4" />
</Portal>
const CounterContext = createContext();

function CounterProvider(props) {
  const [count, setCount] = createSignal(props.count ?? 0);
  return (
    <CounterContext.Provider value={[count, setCount]}>
      {props.children}
    </CounterContext.Provider>
  );
}

function useCounter() {
  const value = useContext(CounterContext);
  if (!value) throw new Error('Missing CounterProvider');
  return value;
}

function Child() {
  const [count, setCount] = useCounter();
  return (
    <button type="button" onClick={() => setCount((n) => n + 1)}>
      {count()}
    </button>
  );
}

Pass a signal (or store) as value so consumers stay reactive without remounting the Provider.

Refs

Attach ref to any host element to get the DOM node (Solid-compatible):

// Callback
<input ref={(el) => { inputEl = el; }} />

// Signal setter (stable function identity)
const [el, setEl] = createSignal(null);
<div ref={setEl} />

// Variable form — requires grainJsx() (rewrites to an assignment callback)
let box;
<div ref={box} />

// Forwarding
function Field(props) {
  return <input ref={props.ref} />;
}
<Field ref={setEl} />

Refs are set when the node is created and cleared with null when it is removed (e.g. inside Show).

TypeScript

Types ship with the package (no extra install). Use the same Vite config with jsxImportSource: 'grainlet':

import { createSignal, render, type Accessor } from 'grainlet';

function App() {
  const [count, setCount] = createSignal(0);
  return (
    <button type="button" onClick={() => setCount((c) => c + 1)}>
      {count()}
    </button>
  );
}

render(App, document.getElementById('app')!);
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "grainlet"
  }
}

grainlet-vite includes typings for grainJsx().

Forms

Formik-inspired forms — import from grainlet/forms:

import {
  FormProvider,
  Form,
  Field,
  ErrorMessage,
  createForm,
  useFormContext,
  required,
  isEmail,
  minLength,
} from 'grainlet/forms';

function Signup() {
  return (
    <FormProvider
      initialValues={{ email: '', password: '' }}
      rules={{
        email: [required('Email is required'), isEmail('Enter a valid email')],
        password: [
          required('Password is required'),
          minLength(8, 'Use at least 8 characters'),
        ],
      }}
      onSubmit={async (values) => {
        await api.signup(values);
      }}
    >
      <Form>
        <Field name="email" type="email" />
        <ErrorMessage name="email" />
        <Field name="password" type="password" />
        <ErrorMessage name="password" />
        <button type="submit">Sign up</button>
      </Form>
    </FormProvider>
  );
}

Headless (no provider):

const form = createForm({
  initialValues: { email: '' },
  rules: { email: [required(), isEmail()] },
  onSubmit: async (values) => { /* … */ },
});
// form.values(), form.handleChange, form.handleSubmit, …

Supports nested paths (social.facebook, friends[0]), field-level validate={[required(), isEmail()]}, optional Yup validationSchema, and FieldArray. Full guide: forms/README.md.

Auth

Headless, Auth.js-style sessions — import from grainlet/auth. Your app owns the API, cookies, and OAuth SDK:

import { Show, render } from 'grainlet';
import {
  AuthProvider,
  Credentials,
  createAuth,
  createLocalStorageAdapter,
  useSession,
  ProtectedRoute,
} from 'grainlet/auth';

const auth = createAuth({
  providers: [
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      authorize: (credentials) =>
        api.post('/api/auth/login', credentials),
    }),
  ],
  refresh: ({ userId, refreshToken }) =>
    api.post('/api/auth/refresh', { userId, refreshToken }),
  storage: createLocalStorageAdapter(),
});

function AccountMenu() {
  const session = useSession();

  return (
    <Show
      when={() => session.status() === 'authenticated'}
      fallback={
        <button
          type="button"
          onClick={() =>
            session.signIn('credentials', {
              email: '[email protected]',
              password: 'secret',
            })
          }
        >
          Sign in
        </button>
      }
    >
      <span>{session.data()?.user.email}</span>
      <button type="button" onClick={() => session.signOut()}>
        Sign out
      </button>
    </Show>
  );
}

function SettingsRoute() {
  return (
    <ProtectedRoute
      redirectTo="/auth/signin"
      loadingFallback={<p>Checking session…</p>}
    >
      <Settings />
    </ProtectedRoute>
  );
}

render(
  () => (
    <AuthProvider client={auth}>
      <AccountMenu />
    </AuthProvider>
  ),
  document.getElementById('app')
);

useSession() returns the auth client. Call accessors: data(), status() (loading | authenticated | unauthenticated), error(). Also: signIn, signOut, getSession, refresh, update. Default storage is memory-only; use createLocalStorageAdapter() to persist across reloads. Full guide: auth/README.md.

For batteries-included Google, Apple, and GitHub sign-in, configure createAuthSdk() and add GoogleSignIn(sdk), AppleSignIn(sdk), and GitHubSignIn(sdk) to the providers array. The SDK handles provider scripts, popups, tokens, and calls to your backend. Full guide: auth-sdk/README.md.

i18n

Namespaced JSON translations — import from grainlet/i18n:

import { render } from 'grainlet';
import { createI18n, I18nProvider, useTranslation } from 'grainlet/i18n';
import enCommon from './locales/en/common.json';
import ukCommon from './locales/uk/common.json';

// en/common.json — { "welcome": "Welcome, {name}", "actions": { "continue": "Continue" } }

const i18n = createI18n({
  locale: 'en',
  fallbackLocale: 'en',
  resources: {
    en: {
      common: enCommon,
      // Lazy namespace: auth: () => import('./locales/en/auth.json'),
    },
    uk: { common: ukCommon },
  },
});

function Welcome() {
  const { t, locale, setLocale } = useTranslation('common');

  return (
    <main>
      <h1>{t('welcome', { name: 'Mike' })}</h1>
      <p>{t('actions.continue')}</p>
      <button type="button" onClick={() => setLocale(locale() === 'en' ? 'uk' : 'en')}>
        Switch language
      </button>
    </main>
  );
}

render(
  () => (
    <I18nProvider client={i18n}>
      <Welcome />
    </I18nProvider>
  ),
  document.getElementById('app')
);

Pass a namespace to useTranslation, then use keys relative to that file. Nested keys use dot paths (segment.title.key). Missing keys fall back to fallbackLocale, then the key string. For form validators, pass lazy messages: required(() => t('validation.required')). Full guide: i18n/README.md.

Query

Async server state — import from grainlet/query. Query and mutation fields are Grainlet accessors (data(), isPending()), not plain properties.

import { QueryClient, QueryClientProvider, useQuery } from 'grainlet/query';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000 } },
});

function Todo({ id }) {
  const todo = useQuery({
    queryKey: ['todo', id],
    queryFn: ({ signal }) =>
      fetch(`/api/todos/${id}`, { signal }).then((r) => r.json()),
  });

  return <h1>{todo.data()?.title}</h1>;
}

Also: useMutation, useInfiniteQuery, useQueries, HydrationBoundary, dehydrate / hydrate. Full guide: query/README.md.

Routing

History API router — no hash required:

import {
  Router,
  Route,
  Link,
  navigate,
  useParams,
  useLocation,
} from 'grainlet/route';

function User() {
  const params = useParams();
  const location = useLocation();
  return (
    <section>
      <h2>User {params().id}</h2>
      <p>{location().pathname}</p>
    </section>
  );
}

function App() {
  return (
    <>
      <nav>
        <Link href="/" activeClass="active">Home</Link>
        <Link href="/users/1" activeClass="active">User 1</Link>
      </nav>
      <Router basename="/app">
        <Route path="/" component={Home} />
        <Route path="/users/:id" component={User} />
        <Route path="*" component={NotFound} />
      </Router>
    </>
  );
}

// Imperative navigation (respects Router basename)
navigate('/users/3');
APIRole
RouterMatch location to Route children (optional basename)
Routepath + component (:param, * catch-all)
LinkClient-side <a>; activeClass when the path matches
navigate(to)Push/replace history
useParams()Signal of route params (params().id)
useLocation()Signal of { pathname, search, hash, state }

Nested layouts, loaders, and actions are opt-in with mode="nested":

import {
  Link,
  Outlet,
  Route,
  Router,
  queryLoader,
  useNavigation,
  useRouteLoaderData,
} from 'grainlet/route';

function Layout() {
  const navigation = useNavigation();
  return (
    <main>
      <Link href="/">Home</Link>
      <p>{() => navigation().state}</p>
      <Outlet />
    </main>
  );
}

function Project() {
  const project = useRouteLoaderData();
  return <h1>{() => project().name}</h1>;
}

<Router mode="nested" queryClient={queryClient} hydrationState={routeState}>
  <Route element={Layout}>
    <Route
      path="projects/:id"
      id="project"
      component={Project}
      loader={queryLoader(({ params }) => ({
        queryKey: ['project', params.id],
        queryFn: () =>
          fetch(`/api/projects/${params.id}`).then((r) => r.json()),
      }))}
    />
  </Route>
</Router>

queryLoader shares the Router queryClient with useQuery. Also: useSubmit, useMatches, useOutletContext, useRouteActionData, useRouteError, useSearchParams, redirect. On the server, prepareRoutes / renderRouteDocument run loaders before HTML; call hydrateRouterState(state, queryClient) before client mount. Full guide: route/README.md.

SSR

import { Suspense, lazy, hydrate } from 'grainlet';
import {
  renderToString,
  renderToStringAsync,
  wrapHtmlDocument,
} from 'grainlet/ssr';

const body = renderToString(App, {}, { url: 'http://localhost/' });

// Await lazy / createResource before emitting HTML (resolved content, not fallback)
const Home = lazy(() => import('./pages/home.js'));
function App() {
  return (
    <Suspense fallback={<p>Loading</p>}>
      <Home />
    </Suspense>
  );
}
const asyncBody = await renderToStringAsync(App);

On the server, createEffect is a no-op; createMemo evaluates once for the HTML snapshot. renderToStringAsync keeps SSR mode on across passes until pending Suspense work settles.

Production apps should use createRequestHandler (Web Response) or the Node createNodeHandler export. Streaming uses renderToReadableStream and renderRouteToReadableStream: the document shell and Suspense fallbacks flush first, then escaped boundary patches replace resolved regions out of order. Redirects from loaders happen before the shell. After shell emission, failures stay inside their boundary and surface through onError.

Always create one QueryClient per request. head is escaped text; trusted markup belongs in unsafeHead. Prefer Title, Meta, HeadLink, Canonical, JsonLd, and OpenGraph. Pass a CSP nonce so state scripts and stream patches inherit it. prerenderPaths / writePrerendered generate static HTML plus a grainlet-prerender.json manifest. Scaffold this pipeline with npx create-grainlet my-app --ssr.

configureHydration({ onMismatch, strict }) reports expected/actual nodes, JSX source, and a component stack. Hydration mismatches replace the subtree on the client unless strict mode throws. Full guide: ssr/README.md.

Testing

grainlet/testing uses the DOM from Vitest, Jest, JSDOM, happy-dom, or a browser. It does not bundle a DOM implementation.

import {
  cleanup,
  fireEvent,
  hydrate,
  render,
  renderHook,
  screen,
  waitFor,
} from 'grainlet/testing';

const view = render(Counter);
fireEvent.click(screen.getByRole('button', { name: 'Increment' }));
await waitFor(() => screen.getByText('1'));
view.unmount();
cleanup();

Pass html to hydrate to attach to existing markup without clearing the container. Full guide: testing/README.md.

Devtools

Install the hook before application signals are created, then render the panel in development only:

import { GrainletDevtools, installDevtoolsHook } from 'grainlet/devtools';

installDevtoolsHook();

<GrainletDevtools queryClient={queryClient} />

The inspector shows signal / effect / owner events and Query cache state. Full guide: devtools/README.md.

版本与发布记录

grainlet 共发布 30 个版本,首次发布于 2026/07/17;过去 12 个月发布了 30 个版本。

版本发布日期距今
1.1.11latest2026/11/04今天
1.1.12026/08/222 个月前
1.1.02026/08/222 个月前
1.0.292026/08/122 个月前
1.0.282026/08/072 个月前
1.0.272026/08/072 个月前

关于 grainlet 的常见问题

grainlet 是什么?

用于web的细粒度信号、JSX、路由和SSR

如何安装 grainlet?

在项目目录运行 npm install grainlet,或使用 pnpm add / yarn add / bun add。

grainlet 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

grainlet 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

grainlet 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

grainlet 可以商用吗?

grainlet 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

grainlet 还在维护吗?

最新版本 1.1.11 发布于 2026/11/04(今天);至今共发布 30 个版本,其中过去 12 个月发布了 30 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。