Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
3 changed files with 290 additions and 2 deletions
Showing only changes of commit e610fa1773 - Show all commits
+1 -1
View File
@@ -1,5 +1,5 @@
---
sidebar_position: 3
sidebar_position: 4
title: Forms
---
+288
View File
@@ -0,0 +1,288 @@
---
sidebar_position: 3
title: Query
---
# Query
The Query module is Effect View's take on TanStack Query. It provides the same
kind of server-state workflow—reactive query keys, cached results, stale times,
background refreshes, window-focus refetching, and cache invalidation—but the
query function is an `Effect` and the observable state is a `View`.
If you already know TanStack Query, the main concepts translate directly:
| TanStack Query concept | Effect View equivalent |
| --- | --- |
| `QueryClient` | The `QueryClient` Effect service |
| `queryKey` | A reactive key supplied as a `View<K>` |
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `refetch` | `query.refresh` or `query.refreshView` |
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
The goal is familiar query behavior without leaving Effect's model. Query
functions keep their typed success, error, and service channels. They can use
services from the runtime, be composed with schema decoding and retry policies,
and are interrupted automatically when their scope ends or a new key supersedes
the current request.
## Provide a QueryClient
Queries share successful results through a `QueryClient`. Add its layer to the
application runtime once, alongside the services used by your query effects:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds",
defaultRefreshOnWindowFocus: true,
cacheGcTime: "5 minutes",
})),
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
```
The client owns the cache and its cleanup lifecycle. Individual queries can
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
client defaults.
## Create a reactive query
A query is driven by a `View` rather than by a value read during one React
render. Whenever that key changes, `Query.service` checks the cache and starts
the query effect when necessary.
```tsx
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/unstable/http"
import { Component, Lens, Query, View } from "effect-view"
const Post = Schema.Struct({
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
const PostView = Component.make("Post")(function* () {
const [postId, query] = yield* Component.useOnMount(() =>
Effect.gen(function* () {
const key = Lens.fromSubscriptionRef(
yield* SubscriptionRef.make(["post", 1 as number] as const),
)
const query = yield* Query.service({
key,
staleTime: "1 minute",
f: ([, id]) =>
HttpClient.HttpClient.pipe(
Effect.andThen((client) =>
client.get(`https://example.com/posts/${id}`),
),
Effect.andThen((response) => response.json),
Effect.andThen(Schema.decodeUnknownEffect(Post)),
),
})
return [Lens.focusTupleAt(key, 1), query] as const
}),
)
const [state] = yield* View.useAll([query.state])
const [id, setId] = yield* Lens.useState(postId)
return (
<section>
<select
value={id}
onChange={(event) => setId(Number(event.currentTarget.value))}
>
<option value={1}>Post 1</option>
<option value={2}>Post 2</option>
</select>
<pre>{state.result._tag}</pre>
</section>
)
})
```
The tuple key plays the same role as `['post', id]` in TanStack Query. Keys use
Effect equality by default, so structurally equal Effect data types work well
as query keys. Supply `keyEquivalence` when the key needs different equality
semantics.
`Query.service` creates the query and starts watching its key in the current
scope. Creating it in `Component.useOnMount` keeps one query instance—and one
stable query function identity—for the component's lifetime.
## Render AsyncResult
`query.state` contains both the current key and an `AsyncResult`:
```ts
interface QueryState<K, A, E> {
readonly key: K
readonly result: AsyncResult.AsyncResult<A, E>
}
```
Subscribe with `View.useAll`, then match the result explicitly:
```tsx
import { AsyncResult } from "effect/unstable/reactivity"
const [state] = yield* View.useAll([query.state])
return AsyncResult.match(state.result, {
onInitial: ({ waiting }) =>
waiting ? <p>Loading…</p> : <p>Not loaded.</p>,
onFailure: ({ cause, previousSuccess, waiting }) => (
<section>
<p>Request failed: {cause.toString()}</p>
{previousSuccess._tag === "Some" && (
<p>Last post: {previousSuccess.value.value.title}</p>
)}
{waiting && <p>Trying again…</p>}
</section>
),
onSuccess: ({ value, waiting }) => (
<article>
{waiting && <small>Refreshing…</small>}
<h2>{value.title}</h2>
<p>{value.body}</p>
</article>
),
})
```
The `waiting` flag separates “do I have a result?” from “is work currently in
flight?”. During a background refresh, a successful result remains successful
and keeps its value while `waiting` becomes `true`. A failed refresh can retain
its `previousSuccess`. This avoids replacing useful content with an empty
loading screen every time data is refreshed.
Failures contain an Effect `Cause<E>`, preserving typed failures, defects, and
interruption information instead of flattening everything into an untyped
exception.
## Change the query key
The key is reactive state, so changing it is enough to fetch the corresponding
resource. There is no separate dependency array to keep synchronized:
```tsx
const [id, setId] = yield* Lens.useState(postId)
return (
<label>
Post
<select
value={id}
onChange={(event) => setId(Number(event.currentTarget.value))}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
)
```
When the key changes, the previous in-flight request is interrupted. The query
then reuses a fresh cached success or starts the effect for the new key.
## Refresh and invalidate
Refresh resolves the latest key again. A fresh cached result can satisfy it
immediately; a stale or missing result runs the query effect while preserving
previous data as background state. Invalidation removes cached data, so the
next fetch for that key must run the effect again.
```tsx
const runSync = yield* Component.useRunSync()
return (
<div>
<button onClick={() => runSync(query.refreshView)}>
Refresh current post
</button>
<button onClick={() => runSync(query.invalidateCacheEntry(["post", id] as const))}>
Invalidate current post
</button>
<button onClick={() => runSync(query.invalidateCache)}>
Invalidate all posts from this query
</button>
</div>
)
```
The methods come in two styles:
| Method | Behavior |
| --- | --- |
| `fetch(key)` | Fetch a specific key and wait for its final state. |
| `fetchView(key)` | Start fetching and immediately return a live state `View`. |
| `refresh` | Resolve the current key again and wait for its final state. |
| `refreshView` | Resolve the current key again and immediately return a live state `View`. |
| `invalidateCacheEntry(key)` | Remove the cached success for one key. |
| `invalidateCache` | Remove every cached success associated with this query function. |
The `*View` variants are convenient in synchronous UI callbacks: they return
after the scoped request has started, while the returned View and `query.state`
continue to publish progress. The non-View variants are useful in Effect
workflows that need to wait for the final success or failure state.
Invalidating does not itself refetch. Follow it with `refreshView`, change the
key, or allow a later fetch to repopulate the cache.
## Staleness and cache lifetime
`staleTime` controls how long a successful result can satisfy a fetch without
running the effect again. A stale entry remains available as previous data
while the query refreshes it.
`cacheGcTime` is configured on `QueryClient`. Entries that have not been
accessed are eventually removed after their stale period plus the configured
garbage-collection time.
By default, the client enables refresh on browser window focus. Set it globally
or override it for one query:
```tsx
const query = yield* Query.service({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnWindowFocus: false,
})
```
Window focus resolves the current key again, subject to the same freshness
check. The browser integration is ignored in non-browser environments.
## The Effect touch
The API emulates TanStack Query's server-state ergonomics, but query execution
remains ordinary Effect code:
- `f` has the full `Effect<A, E, R>` type, including required services.
- The query captures its Effect context when it is created, so callbacks do not
need to manually reconstruct dependencies.
- Effects can use `Schema`, retry schedules, tracing, logging, metrics,
cancellation, and any other Effect operator before becoming query state.
- Request fibers belong to the creation scope and are interrupted on unmount,
key replacement, or scope closure.
- Results are `View`s, so the same state can drive React through `View.useAll`
or participate in Effect streams and application logic outside React.
- Failures retain `Cause<E>` rather than losing Effect's error model.
`Query` is therefore best understood as a TanStack Query-shaped coordinator
for Effect programs: it handles when and whether to run them, while Effect
continues to describe what the work requires and how it behaves.
+1 -1
View File
@@ -3,7 +3,7 @@ import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
const sidebars: SidebarsConfig = {
docsSidebar: ["getting-started", "state-management", "forms"],
docsSidebar: ["getting-started", "state-management", "query", "forms"],
}
export default sidebars