diff --git a/packages/docs/docusaurus.config.ts b/packages/docs/docusaurus.config.ts index b8e2078..618f110 100644 --- a/packages/docs/docusaurus.config.ts +++ b/packages/docs/docusaurus.config.ts @@ -43,6 +43,15 @@ const config: Config = { sidebarPath: "./sidebars.ts", editUrl: "https://github.com/Thiladev/effect-fc/tree/main/packages/docs/", + lastVersion: "current", + versions: { + current: { + label: "v4", + }, + "3": { + label: "v3", + }, + }, }, blog: false, theme: { @@ -70,6 +79,10 @@ const config: Config = { position: "left", label: "Docs", }, + { + type: "docsVersionDropdown", + position: "right", + }, { href: "https://github.com/Thiladev/effect-fc", label: "GitHub", diff --git a/packages/docs/versioned_docs/version-3/forms.md b/packages/docs/versioned_docs/version-3/forms.md new file mode 100644 index 0000000..944234f --- /dev/null +++ b/packages/docs/versioned_docs/version-3/forms.md @@ -0,0 +1,168 @@ +--- +sidebar_position: 3 +title: Forms +--- + +# Forms + +The form modules provide an Effect-first model for editable, schema-validated +state. They are not a visual component library. Instead, they give you +Lenses/Subscribables for form values, validation issues, and commit state, so +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 +value used by inputs and the decoded value produced by an Effect `Schema`: + +- `encodedValue`: a `Lens` containing the raw input value. +- `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. + +There are two root form flavors: + +- `SubmittableForm`: owns local encoded form state, validates it, and submits a + decoded value through a mutation. +- `SynchronizedForm`: wraps an existing target `Lens` and writes valid changes + back to that target. + +Most apps create one of those root forms, then focus it into fields with +`Form.focusObjectOn`, `Form.focusArrayAt`, or the other focusing helpers. + +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 +make those forms convenient to bind to components. + +## 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 label: string + }) { + const input = yield* Form.useInput(props.form, { + debounce: "250 millis", + }) + const [issues] = yield* Subscribable.useAll([props.form.issues]) + + return ( + + ) + }, +) +``` + +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", + { + 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 ( +
{ + event.preventDefault() + void runPromise(state.form.submit) + }} + > + + + + + ) + }, +) +``` + +`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. diff --git a/packages/docs/versioned_docs/version-3/getting-started.md b/packages/docs/versioned_docs/version-3/getting-started.md new file mode 100644 index 0000000..2c4141c --- /dev/null +++ b/packages/docs/versioned_docs/version-3/getting-started.md @@ -0,0 +1,415 @@ +--- +sidebar_position: 1 +title: Getting Started +--- + +# Getting Started + +`effect-fc` lets React components be written as Effect programs. Inside a +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 +app. + +## Install + +Install `effect-fc` alongside `effect` and React 19.2 or newer: + +```bash npm2yarn +npm install effect-fc effect react +``` +```bash npm2yarn +npm install --save-dev @types/react +``` + +`effect-fc` is not opinionated about the React platform. Use it with web, +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 +npm install react-dom +``` +```bash npm2yarn +npm install --save-dev @types/react-dom +``` + +## Create A Runtime + +An Effect-FC app needs an Effect runtime. Build one from the services your UI +needs, then share it with React through `ReactRuntime.Provider`. + +For an empty app, `Layer.empty` is enough: + +```tsx title="src/runtime.ts" +import { Layer } from "effect" +import { ReactRuntime } from "effect-fc" + +export const runtime = ReactRuntime.make(Layer.empty) +``` + +As your app grows, add services to the layer: + +```tsx title="src/runtime.ts" +import { FetchHttpClient } from "@effect/platform" +import { Layer } from "effect" +import { ReactRuntime } from "effect-fc" + +const AppLive = Layer.empty.pipe( + Layer.provideMerge(FetchHttpClient.layer), +) + +export const runtime = ReactRuntime.make(AppLive) +``` + +## Provide The Runtime + +At the React root, wrap your app with `ReactRuntime.Provider`: + +```tsx title="src/main.tsx" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { ReactRuntime } from "effect-fc" +import { App } from "./App" +import { runtime } from "./runtime" + +createRoot(document.getElementById("root")!).render( + + + + + , +) +``` + +`ReactRuntime.Provider` also works with routers. Keep it above your router +provider so route components can be converted with the same runtime context. + +## Write Your First Component + +Use `Component.make` when you want automatic tracing spans, or +`Component.makeUntraced` when you only want the component behavior. This creates +an Effect-FC component, not a plain React component yet. + +The Effect run during render must be synchronous. Effect-FC follows React's +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 { Component } from "effect-fc" + +export const HelloView = Component.make("HelloView")(function* (props: { + readonly name: string +}) { + const message = yield* Effect.succeed(`Hello, ${props.name}`) + + return

{message}

+}) +``` + +At this point `HelloView` can yield Effects, services, and scoped lifecycle +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), +) +``` + +`Hello` is now a normal React component: + +```tsx title="src/App.tsx" +import { Hello } from "./Hello" + +export function App() { + return +} +``` + +## Use Effect-FC Components Together + +Inside an Effect-FC component, other Effect-FC components are available through +their `.use` effect. Yield `.use` to get a React component for the current +runtime context, then render it like JSX. + +```tsx title="src/GreetingCardView.tsx" +import { Component } from "effect-fc" +import { HelloView } from "./HelloView" + +export const GreetingCardView = Component.make("GreetingCard")( + function* () { + const Hello = yield* HelloView.use + + return ( +
+ +

This component is still running inside Effect-FC.

+
+ ) + }, +) +``` + +Use `Component.withRuntime` only when you leave the Effect-FC tree and need a +normal React component again: + +```tsx title="src/GreetingCard.tsx" +import { Component } from "effect-fc" +import { GreetingCardView } from "./GreetingCardView" +import { runtime } from "./runtime" + +export const GreetingCard = GreetingCardView.pipe( + Component.withRuntime(runtime.context), +) +``` + +## 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

{message}

+ }, +) +``` + +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

{label}

+ }, +) +``` + +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 ( + +) +``` + +- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable + React callbacks. + +```tsx +const save = yield* Component.useCallbackPromise( + (user: User) => saveUser(user), + [], +) + +return +``` + +## Use React Normally + +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(null) + + return ( + + ) +}) +``` + +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" +const GreetingView = Component.make("Greeting")(function* (props: { + readonly name: string +}) { + const greeting = yield* GreetingService + + return

{greeting.greet(props.name)}

+}) +``` + +Service values in the runtime context are reactive by default. If a provided +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.useContextFromLayer` when an Effect-FC component should provide +extra services to another Effect-FC component. It turns a `Layer` into a runtime +context that can be provided to `.use`. + +```tsx title="src/GreetingPageView.tsx" +import { Effect } from "effect" +import { Component } from "effect-fc" +import { GreetingView } from "./Greeting" +import { GreetingService } from "./services" + +const GreetingLive = GreetingService.Default({ + greet: (name: string) => `Welcome, ${name}`, +}) + +export const GreetingPageView = Component.make("GreetingPage")(function* () { + const context = yield* Component.useContextFromLayer(GreetingLive) + const Greeting = yield* Effect.provide(GreetingView.use, context) + + return +}) +``` + +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 +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 + +Once the runtime and component boundary are in place, the rest of the library +builds on the same idea: + +- `Subscribable.useAll` reads Effect subscribables and rerenders when they + change. +- `Lens` connects React state and Effect `SubscriptionRef` values. +- `Query` and `Mutation` model async data and user-triggered operations. +- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed + forms. + +The important pattern is small and repeatable: write Effect-FC components inside +the runtime, then use `Component.withRuntime` at React boundaries. diff --git a/packages/docs/versioned_docs/version-3/state-management.md b/packages/docs/versioned_docs/version-3/state-management.md new file mode 100644 index 0000000..160bf96 --- /dev/null +++ b/packages/docs/versioned_docs/version-3/state-management.md @@ -0,0 +1,245 @@ +--- +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", { + 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

Count: {count}

+}) +``` + +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

Count: {count}

+}) +``` + +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` 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", { + 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

Count: {count}, doubled: {doubled}

+ }, +) +``` + +`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 ( +
+

Count: {count}

+ + +
+ ) + }, +) +``` + +## 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", { + 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 ( + 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", { + effect: Effect.gen(function* () { + const profile = Lens.fromSubscriptionRef( + yield* SubscriptionRef.make({ + 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 ( + + ) +}) +``` + +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). diff --git a/packages/docs/versioned_sidebars/version-3-sidebars.json b/packages/docs/versioned_sidebars/version-3-sidebars.json new file mode 100644 index 0000000..068a564 --- /dev/null +++ b/packages/docs/versioned_sidebars/version-3-sidebars.json @@ -0,0 +1,7 @@ +{ + "docsSidebar": [ + "getting-started", + "state-management", + "forms" + ] +} diff --git a/packages/docs/versions.json b/packages/docs/versions.json new file mode 100644 index 0000000..fb052c8 --- /dev/null +++ b/packages/docs/versions.json @@ -0,0 +1,3 @@ +[ + "3" +]