Update docs
Lint / lint (push) Failing after 1m6s

This commit is contained in:
Julien Valverdé committed 2026-10-11 21:51:13 +02:00
1 parent 79d9061907
commit e48f3a6176
6 files changed
+205 -32

No files matched your search

+2 -2
View File
@@ -72,11 +72,11 @@ import { Layer } from "effect"
import { FetchHttpClient } from "effect/http"
import { ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
const layer = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
export const runtime = ReactRuntime.make(layer)
```
Keep the runtime stable. Creating it during a React render would create new
+19
View File
@@ -231,6 +231,25 @@ usually enough to prevent accidental overlap. For “latest request wins”
behavior, model that policy explicitly or use Query when the operation is
actually a reactive read.
## Network mode
Mutations take a `networkMode` option, just like queries. When it is not set,
the `QueryClient`'s `defaultNetworkMode` is used, which is `"online"` unless
you change it. The connection status comes from the `AppConnectivity` service,
as described in [Query](./query.md#network-mode-and-offline-support).
| `networkMode` | Behavior |
| --- | --- |
| `"online"` | A call made offline waits for a connection before running, and `state.paused` is `true` meanwhile. |
| `"always"` | Calls run regardless of the connection. |
```ts
const mutation = yield* Mutation.make({
f: (input: InviteInput) => sendInvite(input),
networkMode: "always",
})
```
## Update queries after a mutation
Mutations do not invalidate Query caches automatically. Compose invalidation
+124 -20
View File
@@ -19,8 +19,12 @@ If you already know TanStack Query, the main concepts translate directly:
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `fetchStatus: 'paused'` | `state.paused` |
| `refetch` | `query.refresh` or `query.refreshStream` |
| `invalidateQueries` | `query.invalidateCache` or `invalidateCacheEntry` |
| `networkMode` | `networkMode` (`"online"` or `"always"`) |
| `onlineManager` | The `AppConnectivity` service |
| `focusManager` | The `AppFocus` service |
The goal is familiar query behavior without leaving Effect's model. Query
functions keep their typed success, error, and service channels. They can use
@@ -32,28 +36,36 @@ interrupted as well, rather than returning a result.
## 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:
application runtime once, alongside the services used by your query effects. In
a browser, use `QueryClient.layerBrowser`, or `QueryClient.layerBrowserWithOptions`
to change the defaults:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerWithOptions({
const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerBrowserWithOptions({
defaultStaleTime: "30 seconds",
defaultRefreshOnWindowFocus: true,
defaultRefreshOnFocus: true,
cacheGcTime: "5 minutes",
})),
)
export const runtime = ReactRuntime.make(AppLive)
export const runtime = ReactRuntime.make(layer)
```
The client owns the cache and its cleanup lifecycle. Individual queries can
override `staleTime` and `refreshOnWindowFocus`; otherwise they inherit the
client defaults. Window-focus refresh also requires the optional
`@effect/platform-browser` package, as described under
[Staleness and cache lifetime](#staleness-and-cache-lifetime).
override `staleTime`, `networkMode`, `refreshOnFocus` and
`refreshOnReconnect`; otherwise they inherit the client defaults
(`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus` and
`defaultRefreshOnReconnect`).
The client needs to know whether the app is focused and online, which it gets
from the `AppFocus` and `AppConnectivity` services. The browser layers provide
both from the window's events. `QueryClient.layer` and
`QueryClient.layerWithOptions` leave them for you to provide, for example on
other platforms such as [React Native](#react-native).
## Create a reactive query
@@ -290,29 +302,121 @@ while the query refreshes it.
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:
By default, the client enables refresh when the app regains focus. Set it
globally or override it for one query:
```tsx
const query = yield* Query.make({
key,
f: loadPost,
staleTime: "10 seconds",
refreshOnWindowFocus: false,
refreshOnFocus: false,
}).pipe(Query.thenRun)
```
Window-focus refresh depends on the optional `@effect/platform-browser`
integration. Install it in browser applications that use this behavior:
Regaining focus resolves the current key again, subject to the same freshness
check. Focus comes from the `AppFocus` service: with
`QueryClient.layerBrowser` it follows the window's `focus` and `blur` events.
Other platforms, such as React Native, provide it themselves, as shown in
[React Native](#react-native).
```bash npm2yarn
npm install @effect/platform-browser
## Network mode and offline support
A query's `networkMode` decides how it behaves without a network connection:
| `networkMode` | Behavior |
| --- | --- |
| `"online"` (default) | A request waits for a connection before running `f`. Meanwhile its state is `paused`, with `result.waiting` still `true`. |
| `"always"` | Requests ignore the connection. Suited to queries that do not use the network. |
Use `state.paused` to tell "loading" apart from "waiting for a connection":
```tsx
const [state] = yield* View.useAll([query.state])
if (state.paused) return <p>Offline. Waiting for a connection…</p>
```
When the package is available, focusing the window resolves the current key
again, subject to the same freshness check. Without it,
`refreshOnWindowFocus` has no effect; the rest of the Query API continues to
work normally. The integration is also ignored in non-browser environments.
With `refreshOnReconnect` (enabled by default in `"online"` mode), regaining
the connection resolves the current key again, subject to the usual freshness
check. Like TanStack Query, focus and reconnect refreshes never restart a
request that is already in flight: a running request keeps running, and one
paused for lack of a connection resumes on its own. A paused request can still
be superseded or interrupted like any other.
The connection status comes from the `AppConnectivity` service. With
`QueryClient.layerBrowser` it follows `navigator.onLine` and the window's
`online`/`offline` events.
### Provide AppConnectivity and AppFocus
Outside a browser, use `QueryClient.layer` and provide both services yourself.
Each module offers:
| Layer or function | Behavior |
| --- | --- |
| `layerBrowser` | Follows the browser's window events. |
| `layerDummy` | Always online, or always focused. Useful for tests and server rendering. |
| `make(view)` | Builds the service from a `View<boolean>`, to provide with `Layer.succeed`. |
```tsx
import { Layer } from "effect"
import { AppConnectivity, AppFocus, QueryClient } from "effect-view"
const queryClientLayer = QueryClient.layer.pipe(
Layer.provide(Layer.mergeAll(AppConnectivity.layerDummy, AppFocus.layerDummy)),
)
```
### React Native
React Native has neither window focus nor `online`/`offline` events. Build
both services from the platform APIs with `make`, and provide them to
`QueryClient.layer`:
```tsx title="src/runtime.ts"
import NetInfo from "@react-native-community/netinfo"
import { Effect, Layer, Queue, Stream } from "effect"
import { AppConnectivity, AppFocus, QueryClient, ReactRuntime, View } from "effect-view"
import { AppState } from "react-native"
const appConnectivityLayer = Layer.succeed(
AppConnectivity.AppConnectivity,
AppConnectivity.make(View.make({
get: Effect.map(Effect.promise(() => NetInfo.fetch()), state => !!state.isConnected),
// NetInfo calls the listener with the current state right away
changes: Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => NetInfo.addEventListener(state => Queue.offerUnsafe(queue, !!state.isConnected))),
unsubscribe => Effect.sync(unsubscribe),
)),
})),
)
const appFocusLayer = Layer.succeed(
AppFocus.AppFocus,
AppFocus.make(View.make({
get: Effect.sync(() => AppState.currentState === "active"),
changes: Stream.concat(
Stream.sync(() => AppState.currentState === "active"),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => AppState.addEventListener("change", status => Queue.offerUnsafe(queue, status === "active"))),
subscription => Effect.sync(() => subscription.remove()),
)),
),
})),
)
const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer),
Layer.provideMerge(appConnectivityLayer),
Layer.provideMerge(appFocusLayer),
)
export const runtime = ReactRuntime.make(layer)
```
A service's `changes` stream starts with the current value, and the listeners
are removed when the layer is released.
## The Effect touch
+4
View File
@@ -76,6 +76,10 @@ The mutation Effect never fails with `E` itself — it captures the operation's
Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own `mutateStream` stream; `mutation.state` (and `latestFinalState`) follow only the most recently started call; earlier calls still run to completion but no longer update them. For a single submit button, disabling while `result.waiting` is usually enough. Use per-call `mutateStream` streams (e.g. per uploaded file) when concurrent operations each need their own progress indicator.
## Network mode
Like queries, `Mutation.make({ f, networkMode? })` takes a `networkMode` (falls back to the `QueryClient`'s `defaultNetworkMode`, `"online"` by default). In `"online"` mode a call made offline waits for the `AppConnectivity` service to report a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). `"always"` ignores the connection.
## Updating queries after a mutation
Mutations never auto-invalidate `Query` caches — compose it explicitly:
+54 -8
View File
@@ -9,28 +9,31 @@ effect-view's take on TanStack Query: reactive query keys, cached results, stale
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
| `isFetching` | `result.waiting` |
| `fetchStatus: 'paused'` | `state.paused` |
| `refetch` | `query.refresh` / `query.refreshStream` |
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
| `networkMode` | `networkMode` (`"online"` / `"always"`) |
| `onlineManager` / `focusManager` | the `AppConnectivity` / `AppFocus` services |
## QueryClient: the shared cache
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime:
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime (browser):
```tsx
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerWithOptions({
const appLayer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layerBrowserWithOptions({
defaultStaleTime: "30 seconds", // default: "0 minutes"
defaultRefreshOnWindowFocus: true, // default: true
defaultRefreshOnFocus: true, // default: true
cacheGcTime: "5 minutes", // default: "5 minutes"
})),
)
export const runtime = ReactRuntime.make(AppLive)
export const runtime = ReactRuntime.make(appLayer)
```
`QueryClient.layer` (defaults) / `QueryClient.layerWithOptions(options)` build the service and fork its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`refreshOnWindowFocus`; unset options fall back to these client defaults. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
`QueryClient.layer` (defaults) / `QueryClient.layerWithOptions(options)` build the service and fork its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`networkMode`/`refreshOnFocus`/`refreshOnReconnect`; unset options fall back to these client defaults (`defaultStaleTime`, `defaultNetworkMode`, `defaultRefreshOnFocus`, `defaultRefreshOnReconnect`). `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. They require the `AppConnectivity` and `AppFocus` services (see "Network mode and offline support"); `QueryClient.layerBrowser` / `QueryClient.layerBrowserWithOptions(options)` provide the browser implementations of both. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
## Create and run a query
@@ -59,7 +62,7 @@ const [postId, query] = yield* Component.useOnMount(() =>
)
```
- `Query.make({ key, f, staleTime?, refreshOnWindowFocus?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope.
- `Query.make({ key, f, staleTime?, networkMode?, refreshOnFocus?, refreshOnReconnect?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope.
- Create each query once and keep it stable — the usual home is `Component.useOnMount` (component-owned) or an Effect service (shared across components). Change the key, don't recreate the query, when input changes.
- Keys use Effect equality by default (`keyEquivalence` overrides it). A tuple key plays the same role as `['post', id]` in TanStack Query.
- `f` keeps its full `Effect<A, E, R>` type: required services, schema decoding, retries, tracing all compose normally. The context is captured at creation time.
@@ -111,7 +114,50 @@ const query = yield* Query.make(options).pipe(
- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes.
- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted.
- `refreshOnWindowFocus` (per query, falls back to `QueryClient` default): re-resolves the current key on window focus. Requires the optional `@effect/platform-browser` package; without it, the option is silently ignored (rest of the API works normally). Also a no-op outside browser environments.
- `refreshOnFocus` (per query, falls back to `QueryClient` default): re-resolves the current key when the app regains focus, as reported by the `AppFocus` service (window `focus`/`blur` events with `AppFocus.layerBrowser`).
## Network mode and offline support
- `networkMode: "online"` (default): a request waits for a connection before running `f`; meanwhile `state.paused` is `true` (with `result.waiting` still `true`). Paused requests can be superseded/interrupted like any other, which clears `paused`.
- `networkMode: "always"`: ignores the connection (for queries that don't use the network).
- `refreshOnReconnect` (defaults to the client default in `"online"` mode, `false` otherwise): re-resolves the current key when the connection returns, subject to freshness. Focus/reconnect refreshes never restart an in-flight request (a paused one resumes by itself), as in TanStack Query.
- The connection comes from the `AppConnectivity` service (`navigator.onLine` + window `online`/`offline` events with `AppConnectivity.layerBrowser`). `AppConnectivity` and `AppFocus` are `Context.Service`s and must be provided to `QueryClient.layer`/`layerWithOptions` (the `layerBrowser*` variants do it for you).
- Each module has `layerBrowser` (window events), `layerDummy` (always online / always focused; tests, SSR) and `make(view: View<boolean>)` to build the service from any `View`, provided with `Layer.succeed`. A `View`'s `changes` stream starts with the current value.
- React Native: build them from platform APIs:
```ts
const appConnectivityLayer = Layer.succeed(
AppConnectivity.AppConnectivity,
AppConnectivity.make(View.make({
get: Effect.map(Effect.promise(() => NetInfo.fetch()), state => !!state.isConnected),
// NetInfo calls the listener with the current state right away
changes: Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => NetInfo.addEventListener(state => Queue.offerUnsafe(queue, !!state.isConnected))),
unsubscribe => Effect.sync(unsubscribe),
)),
})),
)
const appFocusLayer = Layer.succeed(
AppFocus.AppFocus,
AppFocus.make(View.make({
get: Effect.sync(() => AppState.currentState === "active"),
changes: Stream.concat(
Stream.sync(() => AppState.currentState === "active"),
Stream.callback<boolean>(queue => Effect.acquireRelease(
Effect.sync(() => AppState.addEventListener("change", status => Queue.offerUnsafe(queue, status === "active"))),
subscription => Effect.sync(() => subscription.remove()),
)),
),
})),
)
const appLayer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer),
Layer.provideMerge(appConnectivityLayer),
Layer.provideMerge(appFocusLayer),
)
```
## The Effect touch
+2 -2
View File
@@ -8,9 +8,9 @@ Owns a managed Effect runtime and exposes it to a React subtree through context.
import { Layer } from "effect"
import { ReactRuntime } from "effect-view"
const AppLive = Layer.empty // add application layers here
const layer = Layer.empty // add application layers here
export const runtime = ReactRuntime.make(AppLive)
export const runtime = ReactRuntime.make(layer)
```
```tsx