Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 388700b263 |
@@ -1,153 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 3
|
|
||||||
title: Forms
|
|
||||||
---
|
|
||||||
|
|
||||||
# Forms
|
|
||||||
|
|
||||||
The `Form` module is the low-level field model used by Effect-FC forms. A form
|
|
||||||
field is built on the same state model as the rest of Effect-FC:
|
|
||||||
|
|
||||||
- `encodedValue`: a `Lens` containing the raw value used by the input.
|
|
||||||
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
|
||||||
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
|
||||||
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
|
||||||
state.
|
|
||||||
|
|
||||||
Most apps create a root form with `SubmittableForm` or `SynchronizedForm`, then
|
|
||||||
focus it into fields with `Form.focusObjectOn`, `Form.focusArrayAt`, or the
|
|
||||||
other focusing helpers.
|
|
||||||
|
|
||||||
## Field Inputs
|
|
||||||
|
|
||||||
Use `Form.useInput` to connect a `Form` field to a controlled input. It returns
|
|
||||||
a React-style `{ value, setValue }` pair and writes changes back to the field's
|
|
||||||
`encodedValue` Lens.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
const TextInputView = Component.make("TextInput")(
|
|
||||||
function* (props: {
|
|
||||||
readonly form: Form.Form<readonly PropertyKey[], string, string>
|
|
||||||
readonly label: string
|
|
||||||
}) {
|
|
||||||
const input = yield* Form.useInput(props.form, {
|
|
||||||
debounce: "250 millis",
|
|
||||||
})
|
|
||||||
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
{props.label}
|
|
||||||
<input
|
|
||||||
value={input.value}
|
|
||||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
{issues.map((issue) => (
|
|
||||||
<small key={issue.path.join(".")}>
|
|
||||||
{issue.message}
|
|
||||||
</small>
|
|
||||||
))}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `Form.useOptionalInput` for `Option` fields. It returns the same value/setter
|
|
||||||
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
|
||||||
can be toggled on and off.
|
|
||||||
|
|
||||||
## Submittable Forms
|
|
||||||
|
|
||||||
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
|
||||||
with a schema, and then submits a decoded value.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, Schema } from "effect"
|
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
|
||||||
import { TextInputView } from "./TextInputView"
|
|
||||||
|
|
||||||
const RegisterSchema = Schema.Struct({
|
|
||||||
email: Schema.String,
|
|
||||||
password: Schema.String,
|
|
||||||
})
|
|
||||||
|
|
||||||
class RegisterFormState extends Effect.Service<RegisterFormState>()(
|
|
||||||
"RegisterFormState",
|
|
||||||
{
|
|
||||||
scoped: Effect.gen(function* () {
|
|
||||||
const form = yield* SubmittableForm.service({
|
|
||||||
schema: RegisterSchema,
|
|
||||||
initialEncodedValue: {
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
f: ([value]) =>
|
|
||||||
Effect.log(`Registering ${value.email}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
form,
|
|
||||||
email: Form.focusObjectOn(form, "email"),
|
|
||||||
password: Form.focusObjectOn(form, "password"),
|
|
||||||
} as const
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const RegisterFormView = Component.make("RegisterForm")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* RegisterFormState
|
|
||||||
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
|
||||||
state.form.canCommit,
|
|
||||||
state.form.isCommitting,
|
|
||||||
])
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
const TextInput = yield* TextInputView.use
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
void runPromise(state.form.submit)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextInput label="Email" form={state.email} />
|
|
||||||
<TextInput label="Password" form={state.password} />
|
|
||||||
<button disabled={!canCommit}>
|
|
||||||
{isCommitting ? "Submitting..." : "Submit"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
|
||||||
its encoded input state, decoded value, validation issues, submit mutation, and
|
|
||||||
commit flags available as Lenses/Subscribables.
|
|
||||||
|
|
||||||
## Synchronized Forms
|
|
||||||
|
|
||||||
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
|
||||||
Instead of submitting a final value, valid encoded changes are synchronized back
|
|
||||||
to the target Lens.
|
|
||||||
|
|
||||||
This is useful for edit screens where the form is a validated view over existing
|
|
||||||
state. Use `SubmittableForm` for "submit this draft" flows, and
|
|
||||||
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
|
||||||
|
|
||||||
## Focusing Fields
|
|
||||||
|
|
||||||
Forms can be focused just like Lenses:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const emailField = Form.focusObjectOn(form, "email")
|
|
||||||
const firstItemField = Form.focusArrayAt(form, 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
Focused fields keep the parent form's validation and commit state, but narrow
|
|
||||||
`encodedValue`, `value`, and `issues` to the field path. This lets each input
|
|
||||||
component receive only the field it needs.
|
|
||||||
@@ -10,28 +10,26 @@ component body you can yield services, run Effects, subscribe to Effect-powered
|
|||||||
state, and still export a normal React function component at the edge of your
|
state, and still export a normal React function component at the edge of your
|
||||||
app.
|
app.
|
||||||
|
|
||||||
|
This guide starts with the smallest useful setup:
|
||||||
|
|
||||||
|
1. Install `effect-fc` with its peer dependencies.
|
||||||
|
2. Create a React runtime from an Effect `Layer`.
|
||||||
|
3. Wrap your React app with `ReactRuntime.Provider`.
|
||||||
|
4. Write a component with `Component.make`.
|
||||||
|
5. Convert it to a React component with `Component.withRuntime`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
||||||
|
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
npm install effect-fc effect react
|
npm install effect-fc effect react react-dom
|
||||||
```
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install --save-dev @types/react
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`effect-fc` is not opinionated about the React platform. Use it with web,
|
If your project uses TypeScript, also install React's type packages:
|
||||||
native, custom renderers, or any environment where React components can run.
|
|
||||||
Then install the platform-specific React packages for your target.
|
|
||||||
|
|
||||||
For web apps, install React DOM:
|
|
||||||
|
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
npm install react-dom
|
npm install --save-dev @types/react @types/react-dom
|
||||||
```
|
|
||||||
```bash npm2yarn
|
|
||||||
npm install --save-dev @types/react-dom
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create A Runtime
|
## Create A Runtime
|
||||||
@@ -88,46 +86,27 @@ provider so route components can be converted with the same runtime context.
|
|||||||
## Write Your First Component
|
## Write Your First Component
|
||||||
|
|
||||||
Use `Component.make` when you want automatic tracing spans, or
|
Use `Component.make` when you want automatic tracing spans, or
|
||||||
`Component.makeUntraced` when you only want the component behavior. This creates
|
`Component.makeUntraced` when you only want the component behavior.
|
||||||
an Effect-FC component, not a plain React component yet.
|
|
||||||
|
|
||||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
```tsx title="src/Hello.tsx"
|
||||||
render model, so component bodies should produce JSX without waiting on async
|
|
||||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
|
||||||
async work that happens outside render.
|
|
||||||
|
|
||||||
```tsx title="src/HelloView.tsx"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-fc"
|
||||||
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
export const HelloView = Component.make("HelloView")(function* (props: {
|
const HelloEffect = Component.make("HelloEffect")(function* (props: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
}) {
|
}) {
|
||||||
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
const message = yield* Effect.succeed(`Hello, ${props.name}`)
|
||||||
|
|
||||||
return <h1>{message}</h1>
|
return <h1>{message}</h1>
|
||||||
})
|
})
|
||||||
```
|
|
||||||
|
|
||||||
At this point `HelloView` can yield Effects, services, and scoped lifecycle
|
export const Hello = HelloEffect.pipe(
|
||||||
work, but React cannot render it directly.
|
|
||||||
|
|
||||||
## Apply A Runtime
|
|
||||||
|
|
||||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
|
||||||
to become a regular React function component.
|
|
||||||
|
|
||||||
```tsx title="src/Hello.tsx"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import { HelloView } from "./HelloView"
|
|
||||||
import { runtime } from "./runtime"
|
|
||||||
|
|
||||||
export const Hello = HelloView.pipe(
|
|
||||||
Component.withRuntime(runtime.context),
|
Component.withRuntime(runtime.context),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
`Hello` is now a normal React component:
|
`Hello` is now a regular React component:
|
||||||
|
|
||||||
```tsx title="src/App.tsx"
|
```tsx title="src/App.tsx"
|
||||||
import { Hello } from "./Hello"
|
import { Hello } from "./Hello"
|
||||||
@@ -137,267 +116,81 @@ export function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use Effect-FC Components Together
|
## Use Services
|
||||||
|
|
||||||
Inside an Effect-FC component, other Effect-FC components are available through
|
Components can yield Effect services directly. Define services with Effect,
|
||||||
their `.use` effect. Yield `.use` to get a React component for the current
|
provide them in your runtime layer, then consume them from the component body.
|
||||||
runtime context, then render it like JSX.
|
|
||||||
|
|
||||||
```tsx title="src/GreetingCardView.tsx"
|
```ts title="src/services.ts"
|
||||||
import { Component } from "effect-fc"
|
import { Effect } from "effect"
|
||||||
import { HelloView } from "./HelloView"
|
|
||||||
|
|
||||||
export const GreetingCardView = Component.make("GreetingCard")(
|
export class GreetingService extends Effect.Service<GreetingService>()(
|
||||||
function* () {
|
"GreetingService",
|
||||||
const Hello = yield* HelloView.use
|
{
|
||||||
|
succeed: {
|
||||||
return (
|
greet: (name: string) => `Welcome, ${name}`,
|
||||||
<section>
|
},
|
||||||
<Hello name="Effect" />
|
|
||||||
<p>This component is still running inside Effect-FC.</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
)
|
) {}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
Provide the service in your runtime:
|
||||||
normal React component again:
|
|
||||||
|
|
||||||
```tsx title="src/GreetingCard.tsx"
|
```tsx title="src/runtime.ts"
|
||||||
import { Component } from "effect-fc"
|
import { Layer } from "effect"
|
||||||
import { GreetingCardView } from "./GreetingCardView"
|
import { ReactRuntime } from "effect-fc"
|
||||||
import { runtime } from "./runtime"
|
import { GreetingService } from "./services"
|
||||||
|
|
||||||
export const GreetingCard = GreetingCardView.pipe(
|
const AppLive = Layer.empty.pipe(
|
||||||
Component.withRuntime(runtime.context),
|
Layer.provideMerge(GreetingService.Default),
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Lifecycle
|
|
||||||
|
|
||||||
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
|
||||||
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
|
||||||
work, or acquire scoped resources.
|
|
||||||
|
|
||||||
The component scope is created when React mounts the component and closes when
|
|
||||||
React unmounts it. When the scope closes, Effect runs the finalizers registered
|
|
||||||
inside that scope.
|
|
||||||
|
|
||||||
Finalizers are forked when the scope closes, so cleanup logic can run
|
|
||||||
asynchronous Effects even though the component body itself must stay
|
|
||||||
synchronous.
|
|
||||||
|
|
||||||
```tsx title="src/MountedMessageView.tsx"
|
|
||||||
import { Console, Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
|
|
||||||
export const MountedMessageView = Component.make("MountedMessage")(
|
|
||||||
function* () {
|
|
||||||
const message = yield* Component.useOnMount(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Console.log("MountedMessage mounted")
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Console.log("MountedMessage unmounted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return "This value was loaded on mount."
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return <p>{message}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `Component.useOnMount` when the component needs a value produced by an
|
|
||||||
Effect during its first render. The value is then cached for the component
|
|
||||||
instance. You can also use it to set up scoped component logic, subscriptions,
|
|
||||||
or resources that should live for the lifetime of that component instance.
|
|
||||||
|
|
||||||
Use `Component.useOnChange` when render needs the value computed by
|
|
||||||
scoped work that depends on changing inputs. When a dependency changes, React
|
|
||||||
re-renders the component and the hook computes the next value during that
|
|
||||||
render.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Console, Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
|
|
||||||
const UserPanelView = Component.make("UserPanel")(
|
|
||||||
function* (props: { readonly userId: string }) {
|
|
||||||
const label = yield* Component.useOnChange(
|
|
||||||
() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Console.log(`Preparing view for ${props.userId}`)
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Console.log(`Cleaning up ${props.userId}`),
|
|
||||||
)
|
|
||||||
|
|
||||||
return `Viewing user ${props.userId}`
|
|
||||||
}),
|
|
||||||
[props.userId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return <p>{label}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
|
||||||
Effect-FC closes the previous scope, runs its finalizers, and creates a new
|
|
||||||
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
|
||||||
component's root scope directly. It creates and provides its own scope for that
|
|
||||||
dependency window. Some other Effect-FC hooks follow the same pattern when they
|
|
||||||
need a lifecycle that is narrower than the whole component instance.
|
|
||||||
|
|
||||||
## Useful Effect-FC Hooks
|
|
||||||
|
|
||||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
|
||||||
them inside the component body.
|
|
||||||
|
|
||||||
The most common Effect-FC hooks are:
|
|
||||||
|
|
||||||
- `Component.useOnMount`: run an Effect once during the component's first render
|
|
||||||
after mount, and return its value. The Effect must be synchronous.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const initialData = yield* Component.useOnMount(() => loadInitialData)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useOnChange`: when a dependency changes, React re-renders the
|
|
||||||
component and the Effect is run again inside the component body. It returns
|
|
||||||
the latest value, so the Effect must be synchronous too.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
|
|
||||||
that do not need to compute render output or trigger a re-render. The Effect
|
|
||||||
must be synchronous and can register scoped finalizers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
yield* Component.useReactEffect(
|
|
||||||
() => Effect.forkScoped(subscribeToUser(id)),
|
|
||||||
[id],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
|
||||||
finalizers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
|
||||||
event handlers.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const runPromise = yield* Component.useRunPromise()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button onClick={() => void runPromise(saveUser)}>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
|
||||||
React callbacks.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const save = yield* Component.useCallbackPromise(
|
|
||||||
(user: User) => saveUser(user),
|
|
||||||
[],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return <button onClick={() => void save(user)}>Save</button>
|
export const runtime = ReactRuntime.make(AppLive)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use React Normally
|
Then read it inside a component:
|
||||||
|
|
||||||
An Effect-FC component is still a React function component. Anything you can do
|
|
||||||
in a regular React component can also be done in an Effect-FC component,
|
|
||||||
including React hooks, refs, event handlers, context, and JSX composition.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Component } from "effect-fc"
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const CounterView = Component.make("Counter")(function* () {
|
|
||||||
const [count, setCount] = React.useState(0)
|
|
||||||
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
|
|
||||||
Count: {count}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
|
||||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
|
||||||
callbacks.
|
|
||||||
|
|
||||||
## Use Tags
|
|
||||||
|
|
||||||
Components can yield Effect tags directly in the component body. There is no
|
|
||||||
need to memoize the service yourself; just yield the tag wherever the component
|
|
||||||
needs it.
|
|
||||||
|
|
||||||
```tsx title="src/Greeting.tsx"
|
```tsx title="src/Greeting.tsx"
|
||||||
const GreetingView = Component.make("Greeting")(function* (props: {
|
import { Component } from "effect-fc"
|
||||||
|
import { runtime } from "./runtime"
|
||||||
|
import { GreetingService } from "./services"
|
||||||
|
|
||||||
|
const GreetingEffect = Component.make("Greeting")(function* (props: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
}) {
|
}) {
|
||||||
const greeting = yield* GreetingService
|
const greeting = yield* GreetingService
|
||||||
|
|
||||||
return <p>{greeting.greet(props.name)}</p>
|
return <p>{greeting.greet(props.name)}</p>
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const Greeting = GreetingEffect.pipe(
|
||||||
|
Component.withRuntime(runtime.context),
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Service values in the runtime context are reactive by default. If a provided
|
## Mount And Cleanup Effects
|
||||||
service instance changes, Effect-FC unmounts and mounts again the components
|
|
||||||
that depend on that context, so they read the new service and restart their
|
|
||||||
scoped lifecycle with the new environment. For services that should not trigger
|
|
||||||
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
|
||||||
[...] })`.
|
|
||||||
|
|
||||||
## Provide Services To A Subtree
|
Use `Component.useOnMount` for scoped work that should start when the component
|
||||||
|
mounts and finalize when it unmounts.
|
||||||
|
|
||||||
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
```tsx
|
||||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
import { Console, Effect } from "effect"
|
||||||
context that can be provided to `.use`.
|
|
||||||
|
|
||||||
```tsx title="src/GreetingPageView.tsx"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-fc"
|
||||||
import { GreetingView } from "./Greeting"
|
|
||||||
import { GreetingService } from "./services"
|
|
||||||
|
|
||||||
const GreetingLive = GreetingService.Default({
|
const Mounted = Component.make("Mounted")(function* () {
|
||||||
greet: (name: string) => `Welcome, ${name}`,
|
yield* Component.useOnMount(() =>
|
||||||
})
|
Effect.gen(function* () {
|
||||||
|
yield* Console.log("Mounted")
|
||||||
|
yield* Effect.addFinalizer(() => Console.log("Unmounted"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
return <p>Open the console, then unmount me.</p>
|
||||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
|
||||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
|
||||||
|
|
||||||
return <Greeting name="Effect" />
|
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
Finalizers are tied to the component scope, so this is the right place for
|
||||||
is created on every render, Effect-FC has to build a new context, which can
|
subscriptions, resources, and other lifecycle-bound Effects.
|
||||||
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
|
||||||
outside the component, or memoize layers that depend on React props or state.
|
|
||||||
|
|
||||||
Layers built with `useContextFromLayer` are scoped to the component that builds
|
|
||||||
them. That means service construction can register scoped logic, acquire
|
|
||||||
resources, fork scoped fibers, and add finalizers that are tied to that
|
|
||||||
component's lifecycle.
|
|
||||||
|
|
||||||
## Where To Go Next
|
## Where To Go Next
|
||||||
|
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
---
|
|
||||||
sidebar_position: 2
|
|
||||||
title: State Management
|
|
||||||
---
|
|
||||||
|
|
||||||
# State Management
|
|
||||||
|
|
||||||
`Lens` is the main type used for state management in `effect-fc`.
|
|
||||||
|
|
||||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
|
||||||
subscribe to changes, and write updates back to the underlying source. A Lens
|
|
||||||
can point at a whole state object or focus on one nested field inside it.
|
|
||||||
|
|
||||||
The usual pattern is to create state with Effect primitives such as
|
|
||||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
|
||||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
|
||||||
`Subscribable.useAll`.
|
|
||||||
|
|
||||||
`Subscribable` is the read-only side of this model. Every Lens is also a
|
|
||||||
Subscribable.
|
|
||||||
|
|
||||||
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
|
||||||
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
|
||||||
core data model and transformation APIs belong to `effect-lens`, so check the
|
|
||||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/Subscribable API.
|
|
||||||
|
|
||||||
## Where To Store State
|
|
||||||
|
|
||||||
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
|
||||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
|
||||||
the owner based on who needs the state. Once you have a Lens/Subscribable
|
|
||||||
handle, pass it around however you like, including through React props.
|
|
||||||
|
|
||||||
If state is shared by multiple components or belongs to application logic, store
|
|
||||||
it in an Effect service:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
|
|
||||||
return { count } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const CounterValueView = Component.make("CounterValue")(function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
If state belongs to a single Effect-FC component instance, or to a shallow
|
|
||||||
hierarchy of subcomponents that receive it through props, create it with
|
|
||||||
`Component.useOnMount`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
|
||||||
const state = yield* Component.useOnMount(() =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
|
|
||||||
return { count } as const
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
For simple UI state that is not shared and does not need Effect integration,
|
|
||||||
prefer regular React state. A local "show details" toggle is usually better as
|
|
||||||
`React.useState(false)` than as a Lens.
|
|
||||||
|
|
||||||
## Subscribable.useAll
|
|
||||||
|
|
||||||
A `Subscribable<A>` is reactive state with a current value and a stream of
|
|
||||||
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
|
||||||
subscribable values into render output.
|
|
||||||
|
|
||||||
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
|
||||||
from a component.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
|
||||||
const doubled = Subscribable.map(count, (n) => n * 2)
|
|
||||||
|
|
||||||
return { count, doubled } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count, doubled] = yield* Subscribable.useAll([
|
|
||||||
state.count,
|
|
||||||
state.doubled,
|
|
||||||
])
|
|
||||||
|
|
||||||
return <p>Count: {count}, doubled: {doubled}</p>
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
|
||||||
|
|
||||||
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const CounterControlsView = Component.make("CounterControls")(
|
|
||||||
function* () {
|
|
||||||
const state = yield* CounterState
|
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
|
||||||
|
|
||||||
const increment = yield* Component.useCallbackSync(
|
|
||||||
() => Lens.update(state.count, (n) => n + 1),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
const reset = yield* Component.useCallbackSync(
|
|
||||||
() => Lens.set(state.count, 0),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<p>Count: {count}</p>
|
|
||||||
<button onClick={increment}>Increment</button>
|
|
||||||
<button onClick={reset}>Reset</button>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lens.useState
|
|
||||||
|
|
||||||
`Lens.useState` is useful when React needs the familiar `[value, setValue]`
|
|
||||||
tuple, backed by a Lens. Reach for it when the JSX API expects a synchronous
|
|
||||||
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
|
||||||
or third-party components with `value` / `onChange` props.
|
|
||||||
|
|
||||||
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
|
||||||
`Lens.useState` is for places where reading and writing need to be wired
|
|
||||||
together in React's local-state shape.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens } from "effect-fc"
|
|
||||||
|
|
||||||
class FormState extends Effect.Service<FormState>()("FormState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
|
||||||
|
|
||||||
return { name } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const NameInputView = Component.make("NameInput")(function* () {
|
|
||||||
const state = yield* FormState
|
|
||||||
const [name, setName] = yield* Lens.useState(state.name)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
`Lens.useState` returns the current value and a React-compatible setter. Calling
|
|
||||||
the setter writes through the Lens, so every other component subscribed to the
|
|
||||||
same Lens sees the update.
|
|
||||||
|
|
||||||
## Focused Lenses
|
|
||||||
|
|
||||||
Use focused Lenses when a component should work with one part of a larger state
|
|
||||||
object. A focused Lens is still a Lens, so it can be read with
|
|
||||||
`Subscribable.useAll` or used with `Lens.useState` when React needs a
|
|
||||||
read/write tuple.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
|
||||||
|
|
||||||
interface UserProfile {
|
|
||||||
readonly name: string
|
|
||||||
readonly email: string
|
|
||||||
readonly role: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
|
||||||
effect: Effect.gen(function* () {
|
|
||||||
const profile = Lens.fromSubscriptionRef(
|
|
||||||
yield* SubscriptionRef.make<UserProfile>({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
role: "reader",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const name = Lens.focusObjectOn(profile, "name")
|
|
||||||
const role = Lens.focusObjectOn(profile, "role")
|
|
||||||
|
|
||||||
return { profile, name, role } as const
|
|
||||||
}),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
const ProfileNameView = Component.make("ProfileName")(function* () {
|
|
||||||
const state = yield* ProfileState
|
|
||||||
const [name, setName] = yield* Lens.useState(state.name)
|
|
||||||
const [role] = yield* Subscribable.useAll([state.role])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<span>Role: {role}</span>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
|
||||||
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
|
||||||
`Subscribable.useAll` path.
|
|
||||||
|
|
||||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
|
||||||
complete API, refer to the
|
|
||||||
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens).
|
|
||||||
@@ -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...)
|
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||||
|
|
||||||
const sidebars: SidebarsConfig = {
|
const sidebars: SidebarsConfig = {
|
||||||
docsSidebar: ["getting-started", "state-management", "forms"],
|
docsSidebar: ["getting-started"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default sidebars
|
export default sidebars
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-browser": "^0.76.0",
|
"@effect/platform-browser": "^0.76.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^29.0.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user