Skip to content
DocsframeworksDocumentation

Solid

Use Lunora with SolidJS — a provider, live createQuery/createMutation primitives, and the SSR-seed → live reactive-loader handoff.

Last updated:

@lunora/solid is the SolidJS adapter, an idiomatic layer over @lunora/client. Solid's fine-grained signals map directly onto Lunora's per-subscription deltas, so a live query is a signal the socket writes to: only the components that read the accessor re-render.

This page is the task-oriented guide; for the full reference see @lunora/solid.

Preview. The Solid adapter exposes the same hydrate-then-subscribe handoff as React, but it is preview maturity: not yet proven end-to-end against a running app. Solid lands first after React because its signals map most directly onto Lunora deltas. See Bring your framework.

Install

pnpm add @lunora/solid

Solid 1.x and Solid 2.0

@lunora/solid peers on solid-js@^1.9.0 || ^2.0.0-rc.0 and ships one build for both majors — the adapter's public surface (LunoraProvider, createQuery, createMutation, hydratePreloaded, …) is identical on either. Where the two Solids diverge internally, the adapter picks the right primitive at runtime, so nothing about your usage changes.

What does change is the code around it. The snippets on this page are Solid 1.x; on Solid 2.0 the imports and a few APIs move:

Solid 1.xSolid 2.0
solid-js/web@solidjs/web (also jsxImportSource)
solid-js/storesolid-js — stores moved into core
onMountonSettled, which returns its own cleanup
createEffect(fn) / on()createEffect(compute, apply) — two arguments
<Suspense> / <Index><Loading> / <For keyed={false}>
<ErrorBoundary><Errored>
mergeProps / splitPropsmerge / omit
classList={{ … }}class={{ … }}
<Ctx.Provider value={…}><Ctx value={…}> — the context is the provider

Two behaviour changes have no syntactic tell, so they surprise people: signal reads do not update until the microtask flushes (call flush() when a test asserts right after a write), and writing a signal from a component body throws in dev. Built-in DOM attributes are also lowercase now (novalidate, autocomplete, tabindex), though event handlers stay camelCase.

lunora init app -t solid-v2 scaffolds a project already wired for 2.0 (the @solidjs/web renderer, jsxImportSource, and vite-plugin-solid 3.x). lunora init app --vite solid stays on the stable Solid 1.x line.

lunora add auth-ui splits the same way. The copy-in auth screens are source you own, so the two majors cannot share one payload: auth-ui-solid is the 1.x port and auth-ui-solid-v2 the 2.0 one, and the CLI picks from your installed solid-js. The framework-agnostic controllers under lunora/auth-ui/core are identical in both.

Provide the client

Wrap the tree in <LunoraProvider client={...}> (required at the root). Read it anywhere below with useLunora().

src/index.tsx
// Solid 2.0: `import { render } from "@solidjs/web"`.
import { LunoraClient } from "lunorash/client";
import { LunoraProvider } from "@lunora/solid";
import { render } from "solid-js/web";

import App from "./App";

const client = new LunoraClient({ url: window.location.origin });

render(
    () => (
        <LunoraProvider client={client}>
            <App />
        </LunoraProvider>
    ),
    document.getElementById("root")!,
);

Live queries

createQuery(fn, args) subscribes and returns a reactive accessor (undefined until the first frame, then updated on every delta). Pass args as a plain value or an accessor; an accessor re-subscribes when it changes. Pass "skip" to short-circuit.

import { createQuery } from "@lunora/solid";
import { For, createSignal } from "solid-js";

import { api } from "./lunora/_generated/api";

function Messages() {
    const [channelId, setChannelId] = createSignal("general");

    // Accessor args: changing `channelId()` re-subscribes.
    const messages = createQuery(api.messages.list, () => ({ channelId: channelId() }));

    return <For each={messages() ?? []}>{(m) => <li>{m.text}</li>}</For>;
}

Pass { shardKey } in options to target a specific shard when the function is .shardBy(...)-partitioned. The subscription tears down when the owning reactive scope is disposed.

Mutations

createMutation(fn) returns a MutationHandle of accessors: { data, error, pending, mutate, reset }. pending is ref-counted, and the mutation engages the client's offline queue when the socket is down. You pass optimistic updates per call.

import { createMutation } from "@lunora/solid";

import { api } from "./lunora/_generated/api";

function Composer(props: { channelId: string }) {
    const send = createMutation(api.messages.send);

    return (
        <button
            disabled={send.pending()}
            onClick={() =>
                void send.mutate(
                    { channelId: props.channelId, text: "hi" },
                    {
                        optimisticUpdate: (store) => {
                            const current = store.getQuery(api.messages.list, { channelId: props.channelId }) ?? [];
                            store.setQuery(api.messages.list, { channelId: props.channelId }, [...current, { _id: "tmp", text: "hi" }]);
                        },
                    },
                )
            }
        >
            Send
        </button>
    );
}

Reactive loaders

Run the query on the server with the socket-free @lunora/solid/server entry inside a SolidStart route loader, hand the serializable Preloaded token to the client, and hydratePreloaded seeds the accessor synchronously. The first read returns the server value with no loading flash and no Suspense fallback, then the live subscription attaches after mount.

server (SolidStart loader)
import { createServerClient, preloadQuery } from "@lunora/solid/server";

import { api } from "./lunora/_generated/api";

// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env.LUNORA_URL!, token });
const preloaded = await preloadQuery(client, api.posts.list, {});
import { hydratePreloaded } from "@lunora/solid";
import type { Preloaded } from "@lunora/solid";
import { For } from "solid-js";

function Posts(props: { preloaded: Preloaded<Array<{ _id: string; title: string }>> }) {
    // Seeded from SSR on the first read, then live.
    const posts = hydratePreloaded(props.preloaded);

    return <For each={posts()}>{(p) => <li>{p.title}</li>}</For>;
}

Effects do not run during SSR, so the subscription is strictly client-side: the seed is the only value the server render sees. See Reactive loaders for the full handoff.