+45
-51
@@ -7,30 +7,30 @@ title: Forms
|
|||||||
|
|
||||||
The form modules provide an Effect-first model for editable, schema-validated
|
The form modules provide an Effect-first model for editable, schema-validated
|
||||||
state. They are not a visual component library. Instead, they give you
|
state. They are not a visual component library. Instead, they give you
|
||||||
Lenses/Subscribables for form values, validation issues, and commit state, so
|
Lenses/Views for form values, validation issues, and commit state, so
|
||||||
you can build the UI with whatever components your app already uses.
|
you can build the UI with whatever components your app already uses.
|
||||||
|
|
||||||
The base `Form` type represents a field. A field tracks both the raw encoded
|
The base `Form` type represents a field. A field tracks both the raw encoded
|
||||||
value used by inputs and the decoded value produced by an Effect `Schema`:
|
value used by inputs and the decoded value produced by an Effect `Schema`:
|
||||||
|
|
||||||
- `encodedValue`: a `Lens` containing the raw input value.
|
- `encodedValue`: a `Lens` containing the raw input value.
|
||||||
- `value`: a `Subscribable` containing the decoded value as an `Option`.
|
- `value`: a `View` containing the decoded value as an `Option`.
|
||||||
- `issues`: a `Subscribable` containing schema validation issues for that field.
|
- `issues`: a `View` containing schema validation issues for that field.
|
||||||
- `canCommit`, `isValidating`, and `isCommitting`: `Subscribable` flags for UI
|
- `canCommit`, `isValidating`, and `isCommitting`: `View` flags for UI
|
||||||
state.
|
state.
|
||||||
|
|
||||||
There are two root form flavors:
|
There are two root form flavors:
|
||||||
|
|
||||||
- `SubmittableForm`: owns local encoded form state, validates it, and submits a
|
- `MutationForm`: owns local encoded form state, validates it, and submits a
|
||||||
decoded value through a mutation.
|
decoded value through a mutation.
|
||||||
- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes
|
- `LensForm`: wraps an existing target `Lens` and writes valid changes
|
||||||
back to that target.
|
back to that target.
|
||||||
|
|
||||||
Most apps create one of those root forms, then focus it into fields with
|
Most apps create one of those root forms, then focus it into fields with
|
||||||
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers.
|
||||||
|
|
||||||
The core form model is Effect code and can technically be used outside
|
The core form model is Effect code and can technically be used outside
|
||||||
Effect-FC. Effect-FC adds the React-facing hooks, such as `Form.useInput`, that
|
Effect View. Effect View adds the React-facing hooks, such as `Form.useInput`, that
|
||||||
make those forms convenient to bind to components.
|
make those forms convenient to bind to components.
|
||||||
|
|
||||||
## Field Inputs
|
## Field Inputs
|
||||||
@@ -40,7 +40,7 @@ a React-style `{ value, setValue }` pair and writes changes back to the field's
|
|||||||
`encodedValue` Lens.
|
`encodedValue` Lens.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Component, Form, Subscribable } from "effect-fc"
|
import { Component, Form, View } from "effect-view"
|
||||||
|
|
||||||
const TextInputView = Component.make("TextInput")(
|
const TextInputView = Component.make("TextInput")(
|
||||||
function* (props: {
|
function* (props: {
|
||||||
@@ -50,7 +50,7 @@ const TextInputView = Component.make("TextInput")(
|
|||||||
const input = yield* Form.useInput(props.form, {
|
const input = yield* Form.useInput(props.form, {
|
||||||
debounce: "250 millis",
|
debounce: "250 millis",
|
||||||
})
|
})
|
||||||
const [issues] = yield* Subscribable.useAll([props.form.issues])
|
const [issues] = yield* View.useAll([props.form.issues])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label>
|
<label>
|
||||||
@@ -59,8 +59,8 @@ const TextInputView = Component.make("TextInput")(
|
|||||||
value={input.value}
|
value={input.value}
|
||||||
onChange={(event) => input.setValue(event.currentTarget.value)}
|
onChange={(event) => input.setValue(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{issues.map((issue) => (
|
{issues.map((issue, index) => (
|
||||||
<small key={issue.path.join(".")}>
|
<small key={index}>
|
||||||
{issue.message}
|
{issue.message}
|
||||||
</small>
|
</small>
|
||||||
))}
|
))}
|
||||||
@@ -74,14 +74,14 @@ Use `Form.useOptionalInput` for `Option` fields. It returns the same value/sette
|
|||||||
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
pair plus `enabled` and `setEnabled`, which is useful for optional inputs that
|
||||||
can be toggled on and off.
|
can be toggled on and off.
|
||||||
|
|
||||||
## Submittable Forms
|
## Mutation Forms
|
||||||
|
|
||||||
Use `SubmittableForm` when the user edits local encoded form state, validates it
|
Use `MutationForm` when the user edits local encoded form state, validates it
|
||||||
with a schema, and then submits a decoded value.
|
with a schema, and then submits a decoded value.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
|
import { Component, Form, MutationForm, View } from "effect-view"
|
||||||
import { TextInputView } from "./TextInputView"
|
import { TextInputView } from "./TextInputView"
|
||||||
|
|
||||||
const RegisterSchema = Schema.Struct({
|
const RegisterSchema = Schema.Struct({
|
||||||
@@ -89,35 +89,29 @@ const RegisterSchema = Schema.Struct({
|
|||||||
password: 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")(
|
const RegisterFormView = Component.make("RegisterForm")(
|
||||||
function* () {
|
function* () {
|
||||||
const state = yield* RegisterFormState
|
const [form, email, password] = yield* Component.useOnMount(() =>
|
||||||
const [canCommit, isCommitting] = yield* Subscribable.useAll([
|
Effect.gen(function* () {
|
||||||
state.form.canCommit,
|
const form = yield* MutationForm.service({
|
||||||
state.form.isCommitting,
|
schema: RegisterSchema,
|
||||||
|
initialEncodedValue: {
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
f: ([value]) => Effect.log(`Registering ${value.email}`),
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
form,
|
||||||
|
Form.focusObjectOn(form, "email"),
|
||||||
|
Form.focusObjectOn(form, "password"),
|
||||||
|
] as const
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [canCommit, isCommitting] = yield* View.useAll([
|
||||||
|
form.canCommit,
|
||||||
|
form.isCommitting,
|
||||||
])
|
])
|
||||||
const runPromise = yield* Component.useRunPromise()
|
const runPromise = yield* Component.useRunPromise()
|
||||||
const TextInput = yield* TextInputView.use
|
const TextInput = yield* TextInputView.use
|
||||||
@@ -126,12 +120,12 @@ const RegisterFormView = Component.make("RegisterForm")(
|
|||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
void runPromise(state.form.submit)
|
void runPromise(form.submit)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextInput label="Email" form={state.email} />
|
<TextInput label="Email" form={email} />
|
||||||
<TextInput label="Password" form={state.password} />
|
<TextInput label="Password" form={password} />
|
||||||
<button disabled={!canCommit}>
|
<button disabled={!canCommit || isCommitting}>
|
||||||
{isCommitting ? "Submitting..." : "Submit"}
|
{isCommitting ? "Submitting..." : "Submit"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -140,19 +134,19 @@ const RegisterFormView = Component.make("RegisterForm")(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
`SubmittableForm.service` starts validation in the form's scope. The form keeps
|
`MutationForm.service` starts validation in the form's scope. The form keeps
|
||||||
its encoded input state, decoded value, validation issues, submit mutation, and
|
its encoded input state, decoded value, validation issues, submit mutation, and
|
||||||
commit flags available as Lenses/Subscribables.
|
commit flags available as Lenses/Views.
|
||||||
|
|
||||||
## Synchronized Forms
|
## Lens Forms
|
||||||
|
|
||||||
Use `SynchronizedForm` when a form should edit an existing target `Lens`.
|
Use `LensForm` when a form should edit an existing target `Lens`.
|
||||||
Instead of submitting a final value, valid encoded changes are synchronized back
|
Instead of submitting a final value, valid encoded changes are synchronized back
|
||||||
to the target Lens.
|
to the target Lens.
|
||||||
|
|
||||||
This is useful for edit screens where the form is a validated view over existing
|
This is useful for edit screens where the form is a validated view over existing
|
||||||
state. Use `SubmittableForm` for "submit this draft" flows, and
|
state. Use `MutationForm` for "submit this draft" flows, and `LensForm` for
|
||||||
`SynchronizedForm` for "keep this target state synchronized when valid" flows.
|
"keep this target state synchronized when valid" flows.
|
||||||
|
|
||||||
## Focusing Fields
|
## Focusing Fields
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ title: Getting Started
|
|||||||
|
|
||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
`effect-fc` lets React components be written as Effect programs. Inside a
|
`effect-view` lets React components be written as Effect programs. Inside a
|
||||||
component body you can yield services, run Effects, subscribe to Effect-powered
|
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.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
Install `effect-view` alongside Effect 4 and React 19.2 or newer:
|
||||||
|
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
npm install effect-fc effect react
|
npm install effect-view effect react
|
||||||
```
|
```
|
||||||
```bash npm2yarn
|
```bash npm2yarn
|
||||||
npm install --save-dev @types/react
|
npm install --save-dev @types/react
|
||||||
```
|
```
|
||||||
|
|
||||||
`effect-fc` is not opinionated about the React platform. Use it with web,
|
`effect-view` is not opinionated about the React platform. Use it with web,
|
||||||
native, custom renderers, or any environment where React components can run.
|
native, custom renderers, or any environment where React components can run.
|
||||||
Then install the platform-specific React packages for your target.
|
Then install the platform-specific React packages for your target.
|
||||||
|
|
||||||
@@ -36,14 +36,14 @@ npm install --save-dev @types/react-dom
|
|||||||
|
|
||||||
## Create A Runtime
|
## Create A Runtime
|
||||||
|
|
||||||
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
An Effect View app needs an Effect runtime. Build one from the services your UI
|
||||||
needs, then share it with React through `ReactRuntime.Provider`.
|
needs, then share it with React through `ReactRuntime.Provider`.
|
||||||
|
|
||||||
For an empty app, `Layer.empty` is enough:
|
For an empty app, `Layer.empty` is enough:
|
||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
```tsx title="src/runtime.ts"
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
export const runtime = ReactRuntime.make(Layer.empty)
|
export const runtime = ReactRuntime.make(Layer.empty)
|
||||||
```
|
```
|
||||||
@@ -51,9 +51,9 @@ export const runtime = ReactRuntime.make(Layer.empty)
|
|||||||
As your app grows, add services to the layer:
|
As your app grows, add services to the layer:
|
||||||
|
|
||||||
```tsx title="src/runtime.ts"
|
```tsx title="src/runtime.ts"
|
||||||
import { FetchHttpClient } from "@effect/platform"
|
|
||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { ReactRuntime } from "effect-view"
|
||||||
|
|
||||||
const AppLive = Layer.empty.pipe(
|
const AppLive = Layer.empty.pipe(
|
||||||
Layer.provideMerge(FetchHttpClient.layer),
|
Layer.provideMerge(FetchHttpClient.layer),
|
||||||
@@ -69,7 +69,7 @@ At the React root, wrap your app with `ReactRuntime.Provider`:
|
|||||||
```tsx title="src/main.tsx"
|
```tsx title="src/main.tsx"
|
||||||
import { StrictMode } from "react"
|
import { StrictMode } from "react"
|
||||||
import { createRoot } from "react-dom/client"
|
import { createRoot } from "react-dom/client"
|
||||||
import { ReactRuntime } from "effect-fc"
|
import { ReactRuntime } from "effect-view"
|
||||||
import { App } from "./App"
|
import { App } from "./App"
|
||||||
import { runtime } from "./runtime"
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
@@ -83,22 +83,22 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
```
|
```
|
||||||
|
|
||||||
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
||||||
provider so route components can be converted with the same runtime context.
|
provider so route components can use 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. This creates
|
||||||
an Effect-FC component, not a plain React component yet.
|
an Effect View component, not a plain React component yet.
|
||||||
|
|
||||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
The Effect run during render must be synchronous. Effect View follows React's
|
||||||
render model, so component bodies should produce JSX without waiting on async
|
render model, so component bodies should produce JSX without waiting on async
|
||||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
work. Use lifecycle hooks, callbacks, queries, or other Effect View helpers for
|
||||||
async work that happens outside render.
|
async work that happens outside render.
|
||||||
|
|
||||||
```tsx title="src/HelloView.tsx"
|
```tsx title="src/HelloView.tsx"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
|
|
||||||
export const HelloView = Component.make("HelloView")(function* (props: {
|
export const HelloView = Component.make("HelloView")(function* (props: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
@@ -114,16 +114,16 @@ work, but React cannot render it directly.
|
|||||||
|
|
||||||
## Apply A Runtime
|
## Apply A Runtime
|
||||||
|
|
||||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
Use `Component.withContext` at the boundary where an Effect View component needs
|
||||||
to become a regular React function component.
|
to become a regular React function component.
|
||||||
|
|
||||||
```tsx title="src/Hello.tsx"
|
```tsx title="src/Hello.tsx"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
import { HelloView } from "./HelloView"
|
import { HelloView } from "./HelloView"
|
||||||
import { runtime } from "./runtime"
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
export const Hello = HelloView.pipe(
|
export const Hello = HelloView.pipe(
|
||||||
Component.withRuntime(runtime.context),
|
Component.withContext(runtime.context),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,14 +137,14 @@ export function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Use Effect-FC Components Together
|
## Use Effect View Components Together
|
||||||
|
|
||||||
Inside an Effect-FC component, other Effect-FC components are available through
|
Inside an Effect View component, other Effect View components are available through
|
||||||
their `.use` effect. Yield `.use` to get a React component for the current
|
their `.use` effect. Yield `.use` to get a React component for the current
|
||||||
runtime context, then render it like JSX.
|
runtime context, then render it like JSX.
|
||||||
|
|
||||||
```tsx title="src/GreetingCardView.tsx"
|
```tsx title="src/GreetingCardView.tsx"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
import { HelloView } from "./HelloView"
|
import { HelloView } from "./HelloView"
|
||||||
|
|
||||||
export const GreetingCardView = Component.make("GreetingCard")(
|
export const GreetingCardView = Component.make("GreetingCard")(
|
||||||
@@ -154,29 +154,29 @@ export const GreetingCardView = Component.make("GreetingCard")(
|
|||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<Hello name="Effect" />
|
<Hello name="Effect" />
|
||||||
<p>This component is still running inside Effect-FC.</p>
|
<p>This component is still running inside Effect View.</p>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
Use `Component.withContext` only when you leave the Effect View tree and need a
|
||||||
normal React component again:
|
normal React component again:
|
||||||
|
|
||||||
```tsx title="src/GreetingCard.tsx"
|
```tsx title="src/GreetingCard.tsx"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
import { GreetingCardView } from "./GreetingCardView"
|
import { GreetingCardView } from "./GreetingCardView"
|
||||||
import { runtime } from "./runtime"
|
import { runtime } from "./runtime"
|
||||||
|
|
||||||
export const GreetingCard = GreetingCardView.pipe(
|
export const GreetingCard = GreetingCardView.pipe(
|
||||||
Component.withRuntime(runtime.context),
|
Component.withContext(runtime.context),
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Component Lifecycle
|
## Component Lifecycle
|
||||||
|
|
||||||
Every Effect-FC component instance exposes an Effect `Scope`. Effects that need
|
Every Effect View component instance exposes an Effect `Scope`. Effects that need
|
||||||
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
`Scope.Scope` can use that component scope to register finalizers, fork scoped
|
||||||
work, or acquire scoped resources.
|
work, or acquire scoped resources.
|
||||||
|
|
||||||
@@ -190,7 +190,7 @@ synchronous.
|
|||||||
|
|
||||||
```tsx title="src/MountedMessageView.tsx"
|
```tsx title="src/MountedMessageView.tsx"
|
||||||
import { Console, Effect } from "effect"
|
import { Console, Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
|
|
||||||
export const MountedMessageView = Component.make("MountedMessage")(
|
export const MountedMessageView = Component.make("MountedMessage")(
|
||||||
function* () {
|
function* () {
|
||||||
@@ -222,7 +222,7 @@ render.
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Console, Effect } from "effect"
|
import { Console, Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
|
|
||||||
const UserPanelView = Component.make("UserPanel")(
|
const UserPanelView = Component.make("UserPanel")(
|
||||||
function* (props: { readonly userId: string }) {
|
function* (props: { readonly userId: string }) {
|
||||||
@@ -245,18 +245,18 @@ const UserPanelView = Component.make("UserPanel")(
|
|||||||
```
|
```
|
||||||
|
|
||||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
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
|
Effect View closes the previous scope, runs its finalizers, and creates a new
|
||||||
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
|
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
|
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
|
dependency window. Some other Effect View hooks follow the same pattern when they
|
||||||
need a lifecycle that is narrower than the whole component instance.
|
need a lifecycle that is narrower than the whole component instance.
|
||||||
|
|
||||||
## Useful Effect-FC Hooks
|
## Useful Effect View Hooks
|
||||||
|
|
||||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
Unlike plain React hooks, Effect View hooks return Effects. Use `yield*` to run
|
||||||
them inside the component body.
|
them inside the component body.
|
||||||
|
|
||||||
The most common Effect-FC hooks are:
|
The most common Effect View hooks are:
|
||||||
|
|
||||||
- `Component.useOnMount`: run an Effect once during the component's first render
|
- `Component.useOnMount`: run an Effect once during the component's first render
|
||||||
after mount, and return its value. The Effect must be synchronous.
|
after mount, and return its value. The Effect must be synchronous.
|
||||||
@@ -318,12 +318,12 @@ return <button onClick={() => void save(user)}>Save</button>
|
|||||||
|
|
||||||
## Use React Normally
|
## Use React Normally
|
||||||
|
|
||||||
An Effect-FC component is still a React function component. Anything you can do
|
An Effect View component runs as a React function component after it is bound to
|
||||||
in a regular React component can also be done in an Effect-FC component,
|
a runtime context. It can use regular React hooks, refs, event handlers,
|
||||||
including React hooks, refs, event handlers, context, and JSX composition.
|
context, and JSX composition.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
const CounterView = Component.make("Counter")(function* () {
|
const CounterView = Component.make("Counter")(function* () {
|
||||||
@@ -338,7 +338,7 @@ const CounterView = Component.make("Counter")(function* () {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
Use regular React hooks for local UI concerns, and reach for Effect View helpers
|
||||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
when the component needs Effect services, scopes, resources, or Effect-powered
|
||||||
callbacks.
|
callbacks.
|
||||||
|
|
||||||
@@ -349,6 +349,9 @@ need to memoize the service yourself; just yield the tag wherever the component
|
|||||||
needs it.
|
needs it.
|
||||||
|
|
||||||
```tsx title="src/Greeting.tsx"
|
```tsx title="src/Greeting.tsx"
|
||||||
|
import { Component } from "effect-view"
|
||||||
|
import { GreetingService } from "./services"
|
||||||
|
|
||||||
const GreetingView = Component.make("Greeting")(function* (props: {
|
const GreetingView = Component.make("Greeting")(function* (props: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
}) {
|
}) {
|
||||||
@@ -359,7 +362,7 @@ const GreetingView = Component.make("Greeting")(function* (props: {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Service values in the runtime context are reactive by default. If a provided
|
Service values in the runtime context are reactive by default. If a provided
|
||||||
service instance changes, Effect-FC unmounts and mounts again the components
|
service instance changes, Effect View unmounts and mounts again the components
|
||||||
that depend on that context, so they read the new service and restart their
|
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
|
scoped lifecycle with the new environment. For services that should not trigger
|
||||||
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
||||||
@@ -367,30 +370,26 @@ that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
|
|||||||
|
|
||||||
## Provide Services To A Subtree
|
## Provide Services To A Subtree
|
||||||
|
|
||||||
Use `Component.useContextFromLayer` when an Effect-FC component should provide
|
Use `Component.useContextFromLayer` when an Effect View component should provide
|
||||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
extra services to another Effect View component. It turns a `Layer` into a runtime
|
||||||
context that can be provided to `.use`.
|
context that can be provided to `.use`.
|
||||||
|
|
||||||
```tsx title="src/GreetingPageView.tsx"
|
```tsx title="src/GreetingPageView.tsx"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Component } from "effect-fc"
|
import { Component } from "effect-view"
|
||||||
import { GreetingView } from "./Greeting"
|
import { GreetingView } from "./Greeting"
|
||||||
import { GreetingService } from "./services"
|
import { GreetingLive } from "./services"
|
||||||
|
|
||||||
const GreetingLive = GreetingService.Default({
|
|
||||||
greet: (name: string) => `Welcome, ${name}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
||||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
const context = yield* Component.useContextFromLayer(GreetingLive)
|
||||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
const Greeting = yield* GreetingView.use.pipe(Effect.provide(context))
|
||||||
|
|
||||||
return <Greeting name="Effect" />
|
return <Greeting name="Effect" />
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
||||||
is created on every render, Effect-FC has to build a new context, which can
|
is created on every render, Effect View has to build a new context, which can
|
||||||
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
|
||||||
outside the component, or memoize layers that depend on React props or state.
|
outside the component, or memoize layers that depend on React props or state.
|
||||||
|
|
||||||
@@ -404,12 +403,12 @@ component's lifecycle.
|
|||||||
Once the runtime and component boundary are in place, the rest of the library
|
Once the runtime and component boundary are in place, the rest of the library
|
||||||
builds on the same idea:
|
builds on the same idea:
|
||||||
|
|
||||||
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
- `View.useAll` reads Effect views and rerenders when they
|
||||||
change.
|
change.
|
||||||
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
||||||
- `Query` and `Mutation` model async data and user-triggered operations.
|
- `Query` and `Mutation` model async data and user-triggered operations.
|
||||||
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
- `Form`, `MutationForm`, and `LensForm` help build Effect-backed
|
||||||
forms.
|
forms.
|
||||||
|
|
||||||
The important pattern is small and repeatable: write Effect-FC components inside
|
The important pattern is small and repeatable: write Effect View components inside
|
||||||
the runtime, then use `Component.withRuntime` at React boundaries.
|
the runtime, then use `Component.withContext` at React boundaries.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ title: State Management
|
|||||||
|
|
||||||
# State Management
|
# State Management
|
||||||
|
|
||||||
`Lens` is the main type used for state management in `effect-fc`.
|
`Lens` is the main type used for state management in `effect-view`.
|
||||||
|
|
||||||
A Lens is an effectful handle to a piece of state. It can read the current value,
|
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
|
subscribe to changes, and write updates back to the underlying source. A Lens
|
||||||
@@ -14,53 +14,58 @@ 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
|
The usual pattern is to create state with Effect primitives such as
|
||||||
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
`SubscriptionRef`, turn that primitive into a Lens with a matching constructor
|
||||||
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
such as `Lens.fromSubscriptionRef`, and bind Lens values into components with
|
||||||
`Subscribable.useAll`.
|
`View.useAll`.
|
||||||
|
|
||||||
`Subscribable` is the read-only side of this model. Every Lens is also a
|
`View` is the read-only side of this model. Every Lens is also a View.
|
||||||
Subscribable.
|
|
||||||
|
|
||||||
`effect-fc` re-exports the `Lens` and `Subscribable` modules from
|
`effect-view` re-exports the `Lens` and `View` modules from
|
||||||
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
[`effect-lens`](https://github.com/Thiladev/effect-lens) for convenience. The
|
||||||
core data model and transformation APIs belong to `effect-lens`, so check 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.
|
[`effect-lens` documentation](https://github.com/Thiladev/effect-lens/tree/master/packages/effect-lens) for the full Lens/View API.
|
||||||
|
|
||||||
## Where To Store State
|
## Where To Store State
|
||||||
|
|
||||||
State can live pretty much anywhere as a `Lens` or `Subscribable`: in a
|
State can live pretty much anywhere as a `Lens` or `View`: in a
|
||||||
service, in a layer, in a component scope, or alongside plain React state. Pick
|
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
|
the owner based on who needs the state. Once you have a Lens/View
|
||||||
handle, pass it around however you like, including through React props.
|
handle, pass it around however you like, including through React props.
|
||||||
|
|
||||||
If state is shared by multiple components or belongs to application logic, store
|
If state is shared by multiple components or belongs to application logic, store
|
||||||
it in an Effect service:
|
it in an Effect service:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
import { Component, Lens, View } from "effect-view"
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
class CounterState extends Context.Service<
|
||||||
effect: Effect.gen(function* () {
|
CounterState,
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
{ readonly count: Lens.Lens<number> }
|
||||||
|
>()("CounterState") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
CounterState,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||||
|
|
||||||
return { count } as const
|
return { count } as const
|
||||||
}),
|
}),
|
||||||
}) {}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CounterValueView = Component.make("CounterValue")(function* () {
|
const CounterValueView = Component.make("CounterValue")(function* () {
|
||||||
const state = yield* CounterState
|
const state = yield* CounterState
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
const [count] = yield* View.useAll([state.count])
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
return <p>Count: {count}</p>
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
If state belongs to a single Effect-FC component instance, or to a shallow
|
If state belongs to a single Effect View component instance, or to a shallow
|
||||||
hierarchy of subcomponents that receive it through props, create it with
|
hierarchy of subcomponents that receive it through props, create it with
|
||||||
`Component.useOnMount`:
|
`Component.useOnMount`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
import { Effect, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
import { Component, Lens, View } from "effect-view"
|
||||||
|
|
||||||
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
const LocalCounterView = Component.make("LocalCounter")(function* () {
|
||||||
const state = yield* Component.useOnMount(() =>
|
const state = yield* Component.useOnMount(() =>
|
||||||
@@ -70,7 +75,7 @@ const LocalCounterView = Component.make("LocalCounter")(function* () {
|
|||||||
return { count } as const
|
return { count } as const
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
const [count] = yield* View.useAll([state.count])
|
||||||
|
|
||||||
return <p>Count: {count}</p>
|
return <p>Count: {count}</p>
|
||||||
})
|
})
|
||||||
@@ -80,32 +85,40 @@ 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
|
prefer regular React state. A local "show details" toggle is usually better as
|
||||||
`React.useState(false)` than as a Lens.
|
`React.useState(false)` than as a Lens.
|
||||||
|
|
||||||
## Subscribable.useAll
|
## View.useAll
|
||||||
|
|
||||||
A `Subscribable<A>` is reactive state with a current value and a stream of
|
A `View<A>` is reactive state with a current value and a stream of changes. Use
|
||||||
changes. Use `Subscribable.useAll` whenever a component needs to bind
|
`View.useAll` whenever a component needs to bind View values into render output.
|
||||||
subscribable values into render output.
|
|
||||||
|
|
||||||
`Lens` is a `Subscribable`, so this is also the default way to read Lens values
|
`Lens` is a `View`, so this is also the default way to read Lens values
|
||||||
from a component.
|
from a component.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
import { Component, Lens, View } from "effect-view"
|
||||||
|
|
||||||
class CounterState extends Effect.Service<CounterState>()("CounterState", {
|
class CounterState extends Context.Service<
|
||||||
effect: Effect.gen(function* () {
|
CounterState,
|
||||||
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
{
|
||||||
const doubled = Subscribable.map(count, (n) => n * 2)
|
readonly count: Lens.Lens<number>
|
||||||
|
readonly doubled: View.View<number>
|
||||||
|
}
|
||||||
|
>()("CounterState") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
CounterState,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
|
||||||
|
const doubled = View.map(count, (n) => n * 2)
|
||||||
|
|
||||||
return { count, doubled } as const
|
return { count, doubled } as const
|
||||||
}),
|
}),
|
||||||
}) {}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
||||||
function* () {
|
function* () {
|
||||||
const state = yield* CounterState
|
const state = yield* CounterState
|
||||||
const [count, doubled] = yield* Subscribable.useAll([
|
const [count, doubled] = yield* View.useAll([
|
||||||
state.count,
|
state.count,
|
||||||
state.doubled,
|
state.doubled,
|
||||||
])
|
])
|
||||||
@@ -115,7 +128,7 @@ const CounterReadOnlyView = Component.make("CounterReadOnly")(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
`Subscribable.useAll` reads the current values during render and uses scoped subscriptions to update React state when changes arrive.
|
`View.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`:
|
When you need to modify state, write to the Lens with `Lens.set` or `Lens.update`:
|
||||||
|
|
||||||
@@ -123,7 +136,7 @@ When you need to modify state, write to the Lens with `Lens.set` or `Lens.update
|
|||||||
const CounterControlsView = Component.make("CounterControls")(
|
const CounterControlsView = Component.make("CounterControls")(
|
||||||
function* () {
|
function* () {
|
||||||
const state = yield* CounterState
|
const state = yield* CounterState
|
||||||
const [count] = yield* Subscribable.useAll([state.count])
|
const [count] = yield* View.useAll([state.count])
|
||||||
|
|
||||||
const increment = yield* Component.useCallbackSync(
|
const increment = yield* Component.useCallbackSync(
|
||||||
() => Lens.update(state.count, (n) => n + 1),
|
() => Lens.update(state.count, (n) => n + 1),
|
||||||
@@ -152,21 +165,27 @@ 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,
|
setter, especially controlled inputs such as text fields, checkboxes, selects,
|
||||||
or third-party components with `value` / `onChange` props.
|
or third-party components with `value` / `onChange` props.
|
||||||
|
|
||||||
If a component only needs to display the value, prefer `Subscribable.useAll`.
|
If a component only needs to display the value, prefer `View.useAll`.
|
||||||
`Lens.useState` is for places where reading and writing need to be wired
|
`Lens.useState` is for places where reading and writing need to be wired
|
||||||
together in React's local-state shape.
|
together in React's local-state shape.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens } from "effect-fc"
|
import { Component, Lens } from "effect-view"
|
||||||
|
|
||||||
class FormState extends Effect.Service<FormState>()("FormState", {
|
class FormState extends Context.Service<
|
||||||
effect: Effect.gen(function* () {
|
FormState,
|
||||||
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
{ readonly name: Lens.Lens<string> }
|
||||||
|
>()("FormState") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
FormState,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const name = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(""))
|
||||||
|
|
||||||
return { name } as const
|
return { name } as const
|
||||||
}),
|
}),
|
||||||
}) {}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const NameInputView = Component.make("NameInput")(function* () {
|
const NameInputView = Component.make("NameInput")(function* () {
|
||||||
const state = yield* FormState
|
const state = yield* FormState
|
||||||
@@ -189,12 +208,12 @@ same Lens sees the update.
|
|||||||
|
|
||||||
Use focused Lenses when a component should work with one part of a larger state
|
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
|
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
|
`View.useAll` or used with `Lens.useState` when React needs a
|
||||||
read/write tuple.
|
read/write tuple.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Effect, SubscriptionRef } from "effect"
|
import { Context, Effect, Layer, SubscriptionRef } from "effect"
|
||||||
import { Component, Lens, Subscribable } from "effect-fc"
|
import { Component, Lens, View } from "effect-view"
|
||||||
|
|
||||||
interface UserProfile {
|
interface UserProfile {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
@@ -202,26 +221,36 @@ interface UserProfile {
|
|||||||
readonly role: string
|
readonly role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProfileState extends Effect.Service<ProfileState>()("ProfileState", {
|
class ProfileState extends Context.Service<
|
||||||
effect: Effect.gen(function* () {
|
ProfileState,
|
||||||
const profile = Lens.fromSubscriptionRef(
|
{
|
||||||
yield* SubscriptionRef.make<UserProfile>({
|
readonly profile: Lens.Lens<UserProfile>
|
||||||
name: "",
|
readonly name: Lens.Lens<string>
|
||||||
email: "",
|
readonly role: Lens.Lens<string>
|
||||||
role: "reader",
|
}
|
||||||
}),
|
>()("ProfileState") {
|
||||||
)
|
static readonly layer = Layer.effect(
|
||||||
const name = Lens.focusObjectOn(profile, "name")
|
ProfileState,
|
||||||
const role = Lens.focusObjectOn(profile, "role")
|
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
|
return { profile, name, role } as const
|
||||||
}),
|
}),
|
||||||
}) {}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ProfileNameView = Component.make("ProfileName")(function* () {
|
const ProfileNameView = Component.make("ProfileName")(function* () {
|
||||||
const state = yield* ProfileState
|
const state = yield* ProfileState
|
||||||
const [name, setName] = yield* Lens.useState(state.name)
|
const [name, setName] = yield* Lens.useState(state.name)
|
||||||
const [role] = yield* Subscribable.useAll([state.role])
|
const [role] = yield* View.useAll([state.role])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label>
|
<label>
|
||||||
@@ -238,7 +267,7 @@ const ProfileNameView = Component.make("ProfileName")(function* () {
|
|||||||
|
|
||||||
Updating the focused `name` Lens through `Lens.useState` updates the parent
|
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
|
`profile` Lens. The focused `role` Lens is only read, so it stays on the simpler
|
||||||
`Subscribable.useAll` path.
|
`View.useAll` path.
|
||||||
|
|
||||||
For focusing into nested state, deriving lenses, custom write behavior, and the
|
For focusing into nested state, deriving lenses, custom write behavior, and the
|
||||||
complete API, refer to the
|
complete API, refer to the
|
||||||
|
|||||||
Reference in New Issue
Block a user