1 parent
79d9061907
commit
e48f3a6176
6 files changed
+205
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user