Coming from

Coming from Solid

Solid is the closest model, components set up once with fine-grained signals, so this guide is about the differences: signal and store APIs, props, control flow as plain JSX, async components, .stsx styles, islands, and SolidStart and Solid Router mapped to file routes, endpoints and form actions.

This page maps Solid 1.x, Solid Router and SolidStart to Lumiose's current API. The linked pages have the full rules.

#Mental model

The same core idea. As in Solid, a component body runs once, JSX expressions that read signals compile to bindings that update their part of the DOM, and there is no virtual DOM. Owners, onCleanup, context and untrack work the way you expect (Signals). Lumiose's names follow Solid and React where the behaviour matches, without Solid's create prefix: signal, computed, effect, store.

The differences are in the details:

  • A signal is one function, not a [get, set] tuple: count() reads, count.set(1) and count.update((n) => n + 1) write.
  • Props destructure safely. The compiler rewrites destructured component parameters, with defaults and ...rest, into reads of props.x, so ({ title, size = "md" }) stays reactive. No splitProps or mergeProps.
  • Control flow is plain JSX. There is no <Show>, <For> or <Switch>: the compiler turns .map() into a keyed list, ternaries and && into memoised branches, and item.id === selected() inside .map() into a selector (JSX).
  • Async components replace resources. A component may be async and await its data on the server and in the browser; <Suspense> and <ErrorBoundary> work as in Solid (Async components).
  • Styles live with the component. .stsx files are TSX plus raw-CSS <style> blocks, scoped by where they are placed (Styles).

Server first, with islands. SolidStart renders the app on the server and hydrates all of it. In Lumiose, static and server pages ship no JavaScript, and a client:* directive at the usage site makes one component instance an island: <Todos client:load />. An spa route is the SolidStart experience: server-rendered, hydrated, client-side navigation with persistent layouts (Rendering modes).

#From Solid to Lumiose

#Reactivity

Solid Lumiose
const [count, setCount] = createSignal(0) const count = signal(0): count(), count.set(1), count.update((n) => n + 1), count.peek().
createSignal(v, { equals: false }) signal(v, { equals: false }).
createMemo(fn) computed(fn): lazy, cached, and fn receives the previous value.
createEffect(fn) effect(fn); it may return its cleanup, or call onCleanup().
createRenderEffect, createComputed JSX bindings and computed: effects run after the DOM writes of the same flush.
on(deps, fn) Read the dependencies, then untrack(() => …) for the rest.
onMount, onCleanup, batch, untrack The same names.
createStore(value) and setStore(path…, value) store(value): a deep, mutable proxy, written directly (state.todos[0].done = true), like createMutable.
produce(fn) Write to the store directly.
reconcile(data) reconcile(target, data, { key }), with Solid's semantics (reference).
unwrap(store) snapshot(store): a deep plain copy.
createSelector(selected) Compiled for you: item.id === selected() inside .map().
createContext, <Ctx.Provider value>, useContext createContext, <Ctx value> (the context is its own provider), useContext. Provide a signal or a store for state that changes.
createUniqueId() useId(), the same after hydration.
createResource(source, fetcher) An async component that awaits fetchJSON() or query(); resource(fetcher) is the low-level form, with loading(), error() and refetch().
useTransition(), startTransition() Async re-runs keep the old UI until the new data is ready: isPending() inside the component, isBusy() for anything in flight.
isServer isServer from lumiose, folded away in client builds.

#Components and JSX

Solid Lumiose
splitProps(props, ["a"]), mergeProps(defaults, props) ({ a, size = "md", ...rest }): destructuring with defaults and rest stays reactive.
children(() => props.children) Insert children once where it goes; take a render prop to render content several times.
<Show when={user()} fallback={<Login />}> {user() ? <Profile /> : <Login />}, or {user() && <Profile />}.
<Show when={user()} keyed>{(u) => …}</Show> {user() ? <Profile user={user()!} /> : null}: the branch switches when the condition flips, and the prop stays live.
<For each={items()}>{(item, i) => …}</For> {items().map((item, i) => …)}: keyed by reference, as <For>, or by key, which keeps a same-key row and updates its item in place. i is a reactive index, used as a number (i + 1).
<Index each={items()}> {items().map((item, i) => <li key={i}>…</li>)}: rows keyed by position keep their place and get each new item in place.
<Switch> and <Match> A chain of ternaries, or a function that returns JSX: {view()} with const view = () => { switch (status()) { … } }.
<Dynamic component={…}>, <Portal mount={…}> The same: <Dynamic component={…}> and <Portal mount={…}>.
<ErrorBoundary fallback={(err, reset) => …}>, <Suspense> The same.
let el; <div ref={el} /> const el = ref<HTMLDivElement>(), <div ref={el} /> and el(); or a callback ref={(node) => …}.
use:tooltip={text} attach={[tooltip(text)]}, where tooltip(text) returns (el) => cleanup.
classList={{ active: on() }} class={{ active: on() }}; class also takes strings and arrays.
style={{ "font-weight": 600 }}, on:click, onClick The same: kebab-case style keys, native on: listeners and delegated onX handlers.
lazy(() => import("./Chart")) No lazy(): islands load their module when they hydrate, and spa routes load a chunk per page and layout.
render(() => <App />, el), hydrate render(() => <App />, el) from lumiose/dom; islands and spa routes hydrate on their own.
renderToString, renderToStream The same names from lumiose/server (reference).
CSS files, CSS Modules, solid-styled A <style> block in the .stsx file, scoped by placement; <style global> for global rules.
@solidjs/meta: <Title>, <Meta>, <Link>, <MetaProvider> <Title>, <Meta> and <Head> from lumiose, with no provider (Head and document).

#SolidStart

SolidStart Lumiose
app.config.ts (Vinxi) vite.config.ts with the lumiose() plugin.
src/routes/index.tsx, about.tsx src/routes/page.stsx, about/page.stsx: a folder is a URL segment.
blog/[slug].tsx, [[id]].tsx, [...rest].tsx, (group) blog/[slug]/page.stsx, [[id]]/page.stsx, [...rest] (one or more segments) or [[...rest]] (zero or more), (group).
blog.tsx next to blog/ (a layout) blog/layout.stsx; props.children is the outlet.
API routes: export function GET(event) route.ts endpoints: export const GET: Endpoint<"/api/x"> = (ctx) => Response.
query(fn, "key") and createAsync(() => getUser(id)) await query(["user", id], () => getUser(id)) in an async component (key first, an array as in TanStack Query), or await fetchJSON(url).
"use server" functions No RPC: form actions for mutations, endpoints for anything the browser fetches, and *.server.ts modules for server-only code.
action(fn) and <form action={myAction} method="post"> export const actions = { save(ctx) {…} } and <form method="post" action="?/save">, which works without JavaScript.
useAction(myAction) submit(target, { action: "?/save" }) from an island or spa route.
useSubmission(myAction): pending, result submission<typeof actions.save>(): isSubmitting(), result(), error(), abort() and reset() for this component; page-wide, isSubmitting("save") and actionData().
revalidate(key), reload() invalidate(["key"]) refetches matching query() entries; invalidate() refreshes the page.
redirect(), json() from an action redirect(to) (it throws), and plain data as the return value; fail(status, data) for validation errors.
entry-server.tsx with <StartServer document> src/document.stsx renders {props.head} and {props.body}.
entry-client.tsx, app.tsx with <Router root> and <FileRoutes> No files: the router is built in; src/routes/layout.stsx is the root layout. Code before hydration goes in src/instrumentation.client.ts.
createMiddleware({ onRequest, onBeforeResponse }) src/middleware.ts: (ctx, next) => Response, code before and after await next(); a middleware.ts in a route folder for its routes.
event.locals, getRequestEvent() ctx.locals (typed by augmenting Locals); request(), locals(), cookies() and url() during a server render.
useSession() cookies and signedCookie(name) (Server).
clientOnly(() => import("./Map")) client:only on the usage: <Map client:only />.
ssr: false, prerendering (server.prerender) lumiose({ mode: "spa", ssr: false }); static routes (the default) with staticPaths(), and prerender = true on spa pages.
Deployment presets @lumiose/adapter-node, or lumiose({ adapter }) for Vercel, Netlify and Cloudflare (Deploy).

#Solid Router

Solid Router Lumiose
<Router> with <Route path component> File routes; lumiose routes lists them.
<A href>, activeClass <Link href> from lumiose/router, with aria-current="page" on the active link; href() builds typed URLs.
useNavigate(), <Navigate href> navigate(href, { replace }); redirect(to) while rendering.
useParams(), useLocation(), useMatch() params(), url() and route() (the matched pattern) from lumiose/router.
useSearchParams(): [params, setParams] searchParams() and searchParams.set({ q }).
useIsRouting() isNavigating().
useBeforeLeave((e) => e.preventDefault()) beforeNavigate((navigation) => navigation.cancel()); for "leave" (closing the tab, a reload), the browser asks (hooks).
A createEffect on useLocation() (page views) afterNavigate(fn): after each completed navigation, and once on mount with type: "enter".
preload functions on routes None: data loads where it is awaited, and <Link prefetch> loads a page's modules ahead.

#What has no equivalent

Solid Instead
createRoot, getOwner, runWithOwner Not public: components, effects and render() create owners, and async components restore theirs after each await.
createDeferred, createReaction, observable, from None. Bridge an external source with a signal set from its subscription (an external store).
<Index>, mapArray, indexArray .map() in JSX; see the <Index> row above.
<SuspenseList> None: give each <Suspense> its own fallback.
lazy() None: code splits per island and per spa page.
Server functions ("use server") called like functions Form actions and endpoints. Calling an endpoint with fetchJSON() from an island keeps the network boundary visible.
useSubmissions() None: isSubmitting(name) counts every submission to an action.
preload and query preloading on hover <Link prefetch> loads the page's modules; data loads when the page renders.
Solid component libraries (Kobalte, Corvu) They target Solid's runtime and do not run. Use native elements (<dialog>, popover), web components or framework-agnostic libraries.

#Example

A filtered todo list, in Solid:

tsx
import { createMemo, createSignal, For, Show } from "solid-js";
import { createStore } from "solid-js/store";

export function Todos(props: { title: string }) {
  const [todos, setTodos] = createStore([{ id: 1, text: "Write the docs", done: false }]);
  const [filter, setFilter] = createSignal<"all" | "open">("all");
  const visible = createMemo(() =>
    filter() === "open" ? todos.filter((todo) => !todo.done) : todos,
  );

  return (
    <section>
      <h2>{props.title}</h2>
      <button onClick={() => setFilter((f) => (f === "all" ? "open" : "all"))}>
        Show {filter() === "all" ? "open" : "all"}
      </button>
      <Show when={visible().length > 0} fallback={<p>Nothing to do.</p>}>
        <ul>
          <For each={visible()}>
            {(todo, i) => (
              <li classList={{ done: todo.done }}>
                <input
                  type="checkbox"
                  checked={todo.done}
                  onChange={() =>
                    setTodos(
                      (t) => t.id === todo.id,
                      "done",
                      (done) => !done,
                    )
                  }
                />
                {i() + 1}. {todo.text}
              </li>
            )}
          </For>
        </ul>
      </Show>
    </section>
  );
}

The same list in Lumiose, with its styles in the file. The props are destructured, the store is written directly, and the control flow is a ternary and .map():

components/Todos.stsx
// src/components/Todos.stsx
import { computed, signal, store } from "lumiose";

export function Todos({ title }: { title: string }) {
  const todos = store([{ id: 1, text: "Write the docs", done: false }]);
  const filter = signal<"all" | "open">("all");
  const visible = computed(() =>
    filter() === "open" ? todos.filter((todo) => !todo.done) : todos,
  );

  return (
    <section>
      <h2>{title}</h2>
      <button onClick={() => filter.update((f) => (f === "all" ? "open" : "all"))}>
        Show {filter() === "all" ? "open" : "all"}
      </button>
      {visible().length > 0 ? (
        <ul>
          {visible().map((todo, i) => (
            <li key={todo.id} class={{ done: todo.done }}>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => {
                  todo.done = !todo.done;
                }}
              />
              {i + 1}. {todo.text}
            </li>
          ))}
        </ul>
      ) : (
        <p>Nothing to do.</p>
      )}
    </section>
  );
}

<style>
  .done { color: gray; text-decoration: line-through; }
</style>

A SolidStart route that reads with query() and createAsync() and writes with an action() becomes a page with an async component and a form action. Without JavaScript the form posts and the page re-renders with the action's data:

routes/members/[id]/page.stsx
// src/routes/members/[id]/page.stsx
import {
  Title,
  actionData,
  fail,
  href,
  notFound,
  query,
  redirect,
  type ActionContext,
  type PageProps,
} from "lumiose";
import { Todos } from "../../../components/Todos.stsx";

export const mode = "server";

const members = new Map([["1", { name: "Ada", bio: "" }]]); // a database in a real app

export const actions = {
  async save(ctx: ActionContext<"/members/[id]">) {
    const bio = String((await ctx.formData()).get("bio") ?? "");
    if (bio.length > 200) return fail(400, { error: "Keep it under 200 characters." });
    const member = members.get(ctx.params.id) ?? notFound();
    member.bio = bio;
    redirect(href("/members/[id]", { id: ctx.params.id }));
  },
};

export default async function Member({ params }: PageProps<"/members/[id]">) {
  // One query per request and key, however many components await it.
  const member = (await query(["member", params.id], async () => members.get(params.id))) ?? notFound();
  return (
    <main>
      <Title>{member.name}</Title>
      <h1>{member.name}</h1>
      <form method="post" action="?/save">
        <textarea name="bio">{member.bio}</textarea>
        <p>{actionData<typeof actions>()?.error}</p>
        <button>Save</button>
      </form>
      <Todos client:visible title={`${member.name}'s tasks`} />
    </main>
  );
}

#Common mistakes

  • Destructuring a signal tuple. const [count, setCount] = signal(0) is a type error: a signal is one function with .set() and .update().
  • <Show>, <For> or <Switch>. They do not exist. Write ternaries and .map(); the compiler makes them fine-grained.
  • Calling the index: i(). In .map((item, i) => …), i is used as a number; the compiler keeps it reactive. Passing it out of the callback is LUM2006.
  • setStore paths. A store is written directly: todo.done = true, state.items.push(item).
  • splitProps to keep props reactive. Destructure instead; the compiler keeps the reads reactive. Copying a signal's value into a variable is what freezes it (LUM2001).
  • An early return on a signal. if (!user()) return <Login /> runs once, as in Solid; branch in the JSX (LUM2003).
  • query(fn, "key"). Lumiose's query takes the key first, as an array: query(["user", id], fn).
  • An interactive component on a page without client:*. It renders on the server, and its handlers never run. Add a directive at the usage site, or make the route spa.
  • Expecting page context inside an island. Islands start a new root, so context provided by the page does not reach them (LUM4010): pass a prop, or use defineState.
  • A server function imported into an island. Importing a *.server.ts module from browser code is LUM4003. Post to a form action or call an endpoint.