С любовью ко всем разработчикам на свете ❤️
.with is the point builder's swiss-army knife: inject a query, drive the
loading and error states by hand, pass computed props down the chain, or wrap
the render. It's available on every mountable — page,
layout, component, provider — and on
root, base, and plugin, but not on a
mutation or a standalone query.
You've already seen the simple shape: inject a query, map the route into its
input, and read the loaded data in .page:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(({ data: { idea } }) => <h1>{idea.title}</h1>)The page renders only after the injected query loads — the component gets
data, never a loading branch. You can stick more than one query into a single
point; they run in parallel by default, you see the loading state until all of
them have loaded, and on error you see the first one that comes up. To bring
several queries' data into one shape before the render, reach for
.mapper.
Strip category: server-ssr-and-client. Cut from the server bundle when
ssr: false (or after a .clientOnly() earlier in the chain) — its body
and the imports it uses are then removed from the server build; kept in the
client build always, and in the server build only when SSR is on. (It runs at
render: in the browser always, on the server only under SSR.) The full mechanics
are in When the .with body is stripped
below.
The rest of this page covers the other shapes: feeding one query's data into another's input, deriving a query's input from an external hook, and reacting to each query's status before it loads.
.with as a query injectorInjecting a single query into a page — the same shape as above, now also reading
queries:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(
({
// here we have the loaded data from the query
data: { idea },
// here we have the loaded queries themselves
queries: [ideaViewQueryResult],
}) => <h1>{idea.title}</h1>,
)The point you inject doesn't have to be a standalone .query(). Any
point that carries a query works — including a component (or a
layout/page). It's read as its query: the data is injected
and flows into data, while its own UI is not rendered. So a component
built for one screen can be reused elsewhere purely for its data, and the input
is required exactly when that point's input is — the same rule as a query:
export const ideaPage = root.lets
.page('/ideas/:id')
// IdeaStats is a component with its own loader and UI, but here we inject it
// for its query alone: its data lands in `data`, its UI is never rendered
.with(IdeaStats, ({ params }) => ({ id: params.id }))
.page(({ data: { fans } }) => <h1>{fans} fans</h1>)The same thing can be written by passing a function — each .with acts as a
component wrapper around the component below it, so you can call hooks inside:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
// it's specifically by returning the result of useQuery()
// that we get data and queries in the following methods
return ideaViewQuery.useQuery({ id: params.id })
})
// from here on nothing changes
.page(({ data: { idea }, queries: [ideaViewQueryResult] }) => (
<h1>{idea.title}</h1>
))To inject several queries from one function, return an array of them:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
// you can return an array of queries straight from a single .with()
return [ideaViewQuery.useQuery({ id: params.id }), ideaBestQuery.useQuery()]
})
.page(({ queries: [ideaViewQueryResult, ideaBestQueryResult] }) => (
<>
<h1>{ideaViewQueryResult.data.idea.title}</h1>
<h2>{ideaBestQueryResult.data.bestIdea.title}</h2>
</>
))The first way to use one query's data as another's input:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ params }) => {
const ideaViewQueryResult = ideaViewQuery.useQuery({ id: params.id })
// say ideaViewQueryResult.data.idea.similarIds holds the ids of similar ideas
const ideaListQueryResult = ideaListQuery.useQuery(
// the first argument is the input as usual,
// but this isn't very convenient — the input probably expects ids strictly as
// an array, not "array or undefined", so we'd have to tack on `as never`,
// which is awful; that's why another way exists, covered below
{ ids: ideaViewQueryResult.data?.idea.similarIds } as never,
// in the second argument we can pass the options of the classic useQuery,
// and we simply say not to enable this query until ideaViewQueryResult loaded
{ enabled: !!ideaViewQueryResult.data },
)
return [ideaViewQueryResult, ideaListQueryResult]
})
// a disabled query still has a real status of pending,
// so we won't get here until ideaViewQueryResult has loaded
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
<div>
<h1>{ideaViewQueryResult.data.idea.title}</h1>
<h2>Similar ideas</h2>
<ul>
{ideaListQueryResult.data.ideas.map((idea) => (
<li key={idea.id}>{idea.title}</li>
))}
</ul>
</div>
))The enabled trick is clunky; the resolve sections below cover a way I like
more.
.with as a state managerInside a .with function you can short-circuit the chain. Returning a React
element renders it; returning 'loading' or an Error renders the loading or
error component declared up the chain:
export const strangePage = root.lets
.page('/strange')
.with(({ LoadingComponent, ErrorComponent }) => {
// LoadingComponent holds what we declared earlier in .loading()
// ErrorComponent holds what we declared earlier in .error()
const [isLoading, setIsLoading] = useState(true)
const [error] = useState(() =>
Math.random() > 0.5 ? new Error('Oh, how wrong I was') : undefined,
)
useEffect(() => {
setTimeout(() => setIsLoading(false), 1000)
}, [])
if (isLoading) return <LoadingComponent />
if (error) return <ErrorComponent error={error} />
// return undefined — or just nothing — to render the following methods
})
// we won't reach the render until all .with() calls have resolved
.page(() => (
<h1>I loaded something, I don't know what, but I pulled it off</h1>
))There's a shorter notation I like more — the reserved word 'loading' is the
same as <LoadingComponent />, and any instanceof Error is the same as
<ErrorComponent error={...} />:
export const strangePage = root.lets
.page('/strange')
.with(() => {
const [isLoading, setIsLoading] = useState(true)
const [error] = useState(() =>
Math.random() > 0.5 ? new Error('Oh, how wrong I was') : undefined,
)
useEffect(() => {
setTimeout(() => setIsLoading(false), 1000)
}, [])
// same as return <LoadingComponent />
if (isLoading) return 'loading'
// same as return <ErrorComponent error={error} />
if (error) return error
// return nothing to render the following methods
})
.page(() => (
<h1>I loaded something, I don't know what, but I pulled it off</h1>
))An error returned this way can carry an HTTP status, honored during the SSR
render — return new ErrorPoint0('Failed', { status: 500 }). Returning any
string other than 'loading' is a type error, and so is an array of anything
other than query results — a frequent mistake is returning a query's data array
instead of the query itself.
.with as a props injectorTo pass a computed value from one .with hook to another — or to the page
itself — return a plain object: it becomes props, merged into props for every
later method. The merge is a shallow spread ({ ...prev, ...next }), so a later
.with can overwrite an earlier key — even change its type:
export const strangePage = root.lets
.page('/')
.with(() => {
// anything that isn't a react element, an Error, the word 'loading',
// or a query result, is treated as props for the following methods
return { x: 1, y: 2 }
})
.with(({ props: { x, y } }) => {
return {
a: x * 10,
b: y * 100,
// props can be overwritten even with a different type, because in essence
// this is nextProps = { ...prevProps, ...newProps }
x: 'I decided I will be a string',
}
})
.page(({ props: { a, b, x, y } }) => (
<h1>
{a} {b} {x} {y}
</h1>
)).with as a wrapperThe function's argument also carries children — the rendered rest of the
chain. Return them wrapped and you get a boundary around everything downstream:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ children }) => {
return <div style={{ border: '1px solid red' }}>{children}</div>
})
.page(() => <div id="page">Hello!</div>)This differs from returning a self-contained element: an element that doesn't
include children blocks the chain — the rest never renders, and any
queries it would have injected never run. Wrap with children to keep the
chain; return a standalone element to stop it.
.with as an ideaCombining these shapes gives another way to feed one query's data into another's input — the pattern I settled on while building a real project on the framework:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.with(({ queries: [ideaViewQueryResult] }) => {
// unlike .page(), every .with() receives queries in an indeterminate state —
// here it could be an error, loading, or the loaded result, and we handle it
// covered in ".with as a state manager"
if (ideaViewQueryResult.isError) return ideaViewQueryResult.error
if (ideaViewQueryResult.isLoading) return 'loading'
// covered in ".with as a props injector"
return { similarIds: ideaViewQueryResult.data.idea.similarIds }
})
// we won't reach this .with() while the previous one is intercepting control
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
<div>
<h1>{ideaViewQueryResult.data.idea.title}</h1>
<h2>Similar ideas</h2>
<ul>
{ideaListQueryResult.data.ideas.map((idea) => (
<li key={idea.id}>{idea.title}</li>
))}
</ul>
</div>
))All this manual handling of query states is verbose, so there's a resolve
helper. It takes a query result; while the query is loading or erroring it
returns the loading or error state, and on success it maps the data to props
passed further down. The same example, shorter:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.with(({ queries: [ideaViewQueryResult], resolve }) => {
// the same as above, but short
return resolve(ideaViewQueryResult, ({ data }) => ({
similarIds: data.idea.similarIds,
}))
})
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
<div>
<h1>{ideaViewQueryResult.data.idea.title}</h1>
{/* ... */}
</div>
))Resolving before continuing is needed often enough that an even shorter notation
bakes resolve right into the query injection — as the fourth argument:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(
ideaViewQuery,
({ params }) => ({ id: params.id }),
undefined, // useQuery options could go here, not needed now
({ data }) => ({ similarIds: data.idea.similarIds }),
)
.with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
.page(/* ... */)resolve also helps when you want to call a query but don't want it to land
in the queries array or in data. That's handy for the current user — you
want it in props, while data stays free for the page's own data:
export const ideaPage = root.lets
.page('/ideas/:id')
.with(({ resolve }) => {
// since we don't return the useQuery result from this .with(),
// it won't land in queries or data — only the props from resolve() survive
return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
})
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.page(({ data: { idea }, props: { me } }) => (
<div>
<h1>{idea.title}</h1>
<p>Hello, {me.name}!</p>
</div>
)).with(fn) receivesThe function form receives one object. Here's everything on it:
| Key | What |
|---|---|
status / loading / error / data | the accumulated state so far |
props | props contributed by earlier .with calls |
queries | injected queries — in an indeterminate state here (may be loading or errored) |
params / search / input | present when the matching schema exists |
location | present on pages and layouts |
resolve | the resolve helper |
children | the rendered remainder of the chain |
LoadingComponent / ErrorComponent | the components you set with .loading / .error |
There is no ctx here — ctx is server-only and lives in .ctx and
loaders (.ctx is cut from the client bundle, body and imports removed — the
opposite strip category from .with). If you need a ctx value at render, merge
it into props in a plugin first.
| Return | Effect |
|---|---|
| a query result, or an array of them | appended to queries (data = the first) |
'loading' | render the loading component |
an Error | render the error component (status honored in SSR) |
a RedirectTask, or an Error with .redirect | redirect |
a React element (with children) | wrap the rest of the chain |
| a React element (standalone) | render it and stop the chain |
| a plain object | shallow-merge into props |
undefined / nothing | proceed to the next method |
a non-'loading' string, or a non-query array | type error — those shapes are reserved |
Throwing works too. Every mountable render is wrapped in an error boundary, so a
throw inside a .with callback (or anywhere else in the render) is caught and
renders the nearest .error declared above it in the chain — the rest of the
page stays alive. A thrown error carries the same SSR effects as a returned one:
its HTTP status reaches the response, and a thrown redirect becomes the real
HTTP redirect. Returning is still nicer: it's typed, it short-circuits the chain
as data (no boundary involved), and it puts .error() into the server-rendered
HTML itself — a throw ships the loading fallback there and the client renders
.error() after hydration.
resolve formsresolve(query) // wait, contribute nothing to props
resolve(query, true) // wait, then spread the query's data into props
resolve(query, ({ data }) => props) // wait, then map success to propsThe key difference from injecting a query with .with(query, …): a query you
resolve does not land in queries or data — only the props you derive
from it survive. The fourth-argument form (above) is the opposite: it injects
the query and maps extra props alongside it.
.with body is stripped.with is server-ssr-and-client render code, so the rule is symmetric to
other render methods (.page, .layout, .component, .provider, .loading,
.error, .wrapper, .mapper, .head):
.with body — and the imports it pulls in —
are cut when ssr: false, or after a .clientOnly() earlier in
the chain (which makes the rest of the point client-only). It stays in the
server build only when SSR is on, so the point can render server-side..with always ships and runs at
render in the browser. (Server-only methods around it, .ctx, server
.loader, …, are the ones cut from the client bundle; .with itself is not.)That's also why .with runs "at render, on the client and — under SSR — on the
server too": under SSR the first paint is server-rendered, and after that, page
navigations are client-side (SPA-style), running .with in the browser.
.with, not .ctxA .ctx gate runs only when the point has a loader, so a loader-less page
wouldn't run it at all. .with, by contrast, runs at render on every point. For
an authorization gate that always fires, return an Error from .with:
export const authorizedPlugin = Point0.lets
.plugin()
.with(({ props: { me } }) => {
if (!me) {
return new ErrorPoint0('Only for authorized users', {
code: 'UNAUTHORIZED',
})
}
return { me }
})
.plugin()ErrorPoint0 is the framework's default error class; you can swap it for your
own class of the same-or-wider shape via .errorClass(...) — see
error handling. A common production shape combines both gates:
a .ctx that resolves the user for server loaders and a .with that resolves
it for the render — see Plugin.
An Error you return from .with is normalized through that error class and
rendered by the error component. A thrown one lands in the same .error — the
mountable's error boundary catches it — and carries the same HTTP status into
the SSR response; returning stays the better gate simply because it is typed.
See
How a thrown error reaches the error component.