diff --git a/packages/docs/docs/forms.md b/packages/docs/docs/forms.md index 7f0cb1e..d91989a 100644 --- a/packages/docs/docs/forms.md +++ b/packages/docs/docs/forms.md @@ -54,11 +54,9 @@ valid decoded value should go: ## Create forms once -`MutationForm.service` and `LensForm.service` are Effect constructors, not -hooks. Create each root form once and keep it stable. For a component-owned -form, call the constructor inside `Component.useOnMount`; for a form shared by -multiple components, create it in an Effect service. Update the existing form's -Lenses rather than reconstructing the form during render. +`MutationForm.make` and `LensForm.make` construct forms. Pipe them through the +module's `run` operator to start validation or synchronization in the current +scope. Create each root form once and keep it stable. ## MutationForm: validate, then submit @@ -73,7 +71,7 @@ import { Component, MutationForm, View } from "effect-view" const CreateProfileView = Component.make("CreateProfile")(function* () { const form = yield* Component.useOnMount(() => - MutationForm.service({ + MutationForm.make({ schema: ProfileSchema, initialEncodedValue: { displayName: "", @@ -84,7 +82,7 @@ const CreateProfileView = Component.make("CreateProfile")(function* () { Effect.log( `Creating ${profile.displayName}, age ${profile.age}`, ), - }), + }).pipe(MutationForm.run), ) const [canCommit, isCommitting] = yield* View.useAll([ @@ -110,9 +108,9 @@ mutation receives the schema's decoded profile, so `profile.age` is a number. Schema transformations happen before the mutation and schema issues prevent an invalid draft from being submitted. -`MutationForm.service` also starts initial validation in the current scope. In -the example above, `Component.useOnMount` keeps that validation and the form's -mutation work tied to the component that owns them. +`MutationForm.run` starts initial validation. In the example above, +`Component.useOnMount` keeps that validation and the form's mutation work tied +to the component that owns them. ## LensForm: validate, then synchronize @@ -136,10 +134,10 @@ const EditProfileView = Component.make("EditProfile")(function* () { }), ) - const form = yield* LensForm.service({ + const form = yield* LensForm.make({ schema: ProfileSchema, target: profile, - }) + }).pipe(LensForm.run) return [form, profile] as const }), @@ -168,8 +166,8 @@ reaches the target. If another part of the application updates the target, `LensForm` encodes that value back into the draft. Pass `initialEncodedValue` only when the first draft should differ from the -encoded target. Otherwise `LensForm.service` obtains the initial draft by -encoding the target through the schema. +encoded target. Otherwise `LensForm.make` obtains the initial draft by encoding +the target through the schema. ## Focus into subforms @@ -427,14 +425,14 @@ const AppointmentSchema = Schema.Struct({ const AppointmentView = Component.make("Appointment")(function* () { const [form, startsAtField] = yield* Component.useOnMount(() => Effect.gen(function* () { - const form = yield* MutationForm.service({ + const form = yield* MutationForm.make({ schema: AppointmentSchema, initialEncodedValue: { startsAt: "" }, f: ([appointment]) => Effect.log( `Saving ${DateTime.formatIso(appointment.startsAt)}`, ), - }) + }).pipe(MutationForm.run) return [form, Form.focusObjectOn(form, "startsAt")] as const }), diff --git a/packages/docs/docs/query.md b/packages/docs/docs/query.md index b5864a6..c67fd65 100644 --- a/packages/docs/docs/query.md +++ b/packages/docs/docs/query.md @@ -57,11 +57,11 @@ client defaults. Window-focus refresh also requires the optional ## Create a reactive query A query is driven by a `View` rather than by a value read during one React -render. Whenever that key changes, `Query.service` checks the cache and starts +render. Whenever that key changes, a running Query checks the cache and starts the query effect when necessary. -`Query.service` is an Effect constructor, not a hook. Create each query instance -once and keep it stable. In a component, the usual place is +`Query.make` constructs the Query and `Query.run` starts it in the current +scope. Create each query instance once and keep it stable. In a component, the usual place is `Component.useOnMount`; a query shared by multiple components can instead be owned by an Effect service. Change the existing query's reactive key rather than reconstructing the query during render. @@ -84,7 +84,7 @@ const PostView = Component.make("Post")(function* () { yield* SubscriptionRef.make(["post", 1 as number] as const), ) - const query = yield* Query.service({ + const query = yield* Query.make({ key, staleTime: "1 minute", f: ([, id]) => @@ -95,7 +95,7 @@ const PostView = Component.make("Post")(function* () { Effect.andThen((response) => response.json), Effect.andThen(Schema.decodeUnknownEffect(Post)), ), - }) + }).pipe(Query.run) return [Lens.focusTupleAt(key, 1), query] as const }), @@ -124,7 +124,7 @@ Effect equality by default, so structurally equal Effect data types work well as query keys. Supply `keyEquivalence` when the key needs different equality semantics. -`Query.service` starts watching its key in the current scope. The +`Query.run` starts watching its key in the current scope. The `Component.useOnMount` call above keeps both the query instance and its query function identity stable for the component's lifetime. @@ -255,7 +255,8 @@ Refresh every five minutes, starting after five minutes: ```ts import { Schedule } from "effect" -yield* query.pipe( +const query = yield* Query.make(options).pipe( + Query.run, Query.withScheduledRefresh(Schedule.spaced("5 minutes")), ) ``` @@ -263,7 +264,8 @@ yield* query.pipe( Limit the number of refreshes: ```ts -yield* query.pipe( +const query = yield* Query.make(options).pipe( + Query.run, Query.withScheduledRefresh( Schedule.spaced("5 minutes").pipe( Schedule.upTo({ times: 3 }), @@ -289,12 +291,12 @@ By default, the client enables refresh on browser window focus. Set it globally or override it for one query: ```tsx -const query = yield* Query.service({ +const query = yield* Query.make({ key, f: loadPost, staleTime: "10 seconds", refreshOnWindowFocus: false, -}) +}).pipe(Query.run) ``` Window-focus refresh depends on the optional `@effect/platform-browser` diff --git a/packages/effect-view/src/LensForm.ts b/packages/effect-view/src/LensForm.ts index 11debd1..ec85983 100644 --- a/packages/effect-view/src/LensForm.ts +++ b/packages/effect-view/src/LensForm.ts @@ -189,18 +189,13 @@ export const make = Effect.fnUntraced(function* - extends make.Options {} -} - -export const service = ( - options: service.Options +export const run = ( + self: Effect.Effect, E, R>, ): Effect.Effect< LensForm, - Schema.SchemaError | TER, - Scope.Scope | RD | RE | TRR | TRW + E, + Scope.Scope | R > => Effect.tap( - make(options), + self, form => Effect.forkScoped(form.run), ) diff --git a/packages/effect-view/src/MutationForm.ts b/packages/effect-view/src/MutationForm.ts index 160bbdb..1204067 100644 --- a/packages/effect-view/src/MutationForm.ts +++ b/packages/effect-view/src/MutationForm.ts @@ -196,18 +196,13 @@ export const make = Effect.fnUntraced(function* - extends make.Options {} -} - -export const service = ( - options: service.Options +export const run = ( + self: Effect.Effect, E, R>, ): Effect.Effect< MutationForm, - never, - Scope.Scope | RD | RE | MR + E, + Scope.Scope | R > => Effect.tap( - make(options), + self, form => Effect.forkScoped(form.run), ) diff --git a/packages/effect-view/src/Query.test.ts b/packages/effect-view/src/Query.test.ts index 2a50873..866ef88 100644 --- a/packages/effect-view/src/Query.test.ts +++ b/packages/effect-view/src/Query.test.ts @@ -92,14 +92,14 @@ describe("Query", () => { return calls }), staleTime: "0 millis", - }) - - yield* query.fetch([1]) - const returnedQuery = yield* query.pipe(Query.withScheduledRefresh( - Schedule.spaced("1 second").pipe( - Schedule.upTo({ times: 1 }), + }).pipe( + Query.run, + Query.withScheduledRefresh( + Schedule.spaced("1 second").pipe( + Schedule.upTo({ times: 1 }), + ), ), - )) + ) yield* TestClock.adjust("999 millis") const beforeInterval = calls @@ -108,7 +108,7 @@ describe("Query", () => { const afterFirstInterval = calls return { - returnsQuery: returnedQuery === query, + isQuery: Query.isQuery(query), beforeInterval, afterFirstInterval, } @@ -117,7 +117,7 @@ describe("Query", () => { )) expect(result).toEqual({ - returnsQuery: true, + isQuery: true, beforeInterval: 1, afterFirstInterval: 2, }) @@ -180,14 +180,14 @@ describe("Query", () => { const key = staticKey([1]) const effect = Effect.gen(function*() { - const query = yield* Query.service({ + const query = yield* Query.make({ key, f: ([id]: readonly [number]) => Effect.sync(() => { calls += 1 return `value:${id}:${calls}` }), staleTime: "1 minute", - }) + }).pipe(Query.run) const latestFinalState = yield* Effect.sleep("1 millis").pipe( Effect.andThen(View.get(query.latestFinalState)), diff --git a/packages/effect-view/src/Query.ts b/packages/effect-view/src/Query.ts index f170cab..aa118b1 100644 --- a/packages/effect-view/src/Query.ts +++ b/packages/effect-view/src/Query.ts @@ -410,14 +410,10 @@ export const make = Effect.fnUntraced(function* ( ) }) -export const service = ( - options: make.Options -): Effect.Effect< - Query, - Cause.NoSuchElementError, - Scope.Scope | QueryClient.QueryClient | R -> => Effect.tap( - make(options), +export const run = ( + self: Effect.Effect, E2, R2>, +): Effect.Effect, E2, Scope.Scope | R2> => Effect.tap( + self, query => Effect.forkScoped(query.run), ) @@ -426,14 +422,16 @@ export const service = ( * * @example Refresh every five minutes, starting after five minutes * ```ts - * yield* query.pipe( + * yield* Query.make(options).pipe( + * Query.run, * Query.withScheduledRefresh(Schedule.spaced("5 minutes")), * ) * ``` * * @example Refresh at most three times * ```ts - * yield* query.pipe( + * yield* Query.make(options).pipe( + * Query.run, * Query.withScheduledRefresh( * Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 })), * ), @@ -443,19 +441,19 @@ export const service = ( export const withScheduledRefresh: { ( schedule: Schedule.Schedule, - ): ( - self: Query, - ) => Effect.Effect, never, Scope.Scope | Env> - ( - self: Query, + ): ( + self: Effect.Effect, E2, R2>, + ) => Effect.Effect, E2, Scope.Scope | Env | R2> + ( + self: Effect.Effect, E2, R2>, schedule: Schedule.Schedule, - ): Effect.Effect, never, Scope.Scope | Env> -} = Function.dual(2, ( - self: Query, + ): Effect.Effect, E2, Scope.Scope | Env | R2> +} = Function.dual(2, ( + self: Effect.Effect, E2, R2>, schedule: Schedule.Schedule, -) => Effect.schedule(self.refresh, schedule).pipe( - Effect.forkScoped, - Effect.as(self), +) => Effect.tap( + self, + query => Effect.forkScoped(Effect.schedule(query.refresh, schedule)), )) diff --git a/packages/effect-view/src/QueryClient.ts b/packages/effect-view/src/QueryClient.ts index 6868fca..7a0d89f 100644 --- a/packages/effect-view/src/QueryClient.ts +++ b/packages/effect-view/src/QueryClient.ts @@ -115,18 +115,14 @@ export const make = Effect.fnUntraced(function* ( ) }) -export declare namespace service { - export interface Options extends make.Options {} -} - -export const service = ( - options?: service.Options -): Effect.Effect => Effect.tap( - make(options), +export const run = ( + self: Effect.Effect, +): Effect.Effect => Effect.tap( + self, client => Effect.forkScoped(client.run), ) -export const layer = (options?: service.Options) => Layer.effect(QueryClient, service(options)) +export const layer = (options?: make.Options) => Layer.effect(QueryClient, run(make(options))) export const QueryClientCacheKeyTypeId: unique symbol = Symbol.for("@effect-view/QueryClient/QueryClientCacheKey") diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index a6e0cbc..02fb620 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -26,11 +26,13 @@ const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() { })) const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() { - const form = yield* MutationForm.service({ + const form = yield* MutationForm.make({ schema: RegisterSchema, initialEncodedValue: { email: "", password: "" }, f: ([value]) => Effect.log(`Registered ${value.email}`), - }) + }).pipe( + MutationForm.run, + ) const emailField = Form.focusObjectOn(form, "email") const passwordField = Form.focusObjectOn(form, "password") diff --git a/packages/example/src/routes/lensform.tsx b/packages/example/src/routes/lensform.tsx index 95f8030..2841822 100644 --- a/packages/example/src/routes/lensform.tsx +++ b/packages/example/src/routes/lensform.tsx @@ -65,14 +65,16 @@ const UserProfileEditorView = Component.make("UserProfileEditorView")(function*( Stream.runForEach(Lens.changes(appState.lens), Console.log) ) - const form = yield* LensForm.service({ + const form = yield* LensForm.make({ schema: UserProfileSchema, target: Lens.focusObjectOn(appState.lens, "currentUser"), initialEncodedValue: { email: "", password: "", }, - }) + }).pipe( + LensForm.run, + ) const emailField = Form.focusObjectOn(form, "email") const passwordField = Form.focusObjectOn(form, "password") diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 22b5a39..da260e9 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -36,16 +36,18 @@ const QueryRouteComponent = Component.make("QueryRouteView")(function*() { const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) const idLens = Lens.focusTupleAt(keyLens, 1) - const query = yield* Query.service({ + const query = yield* Query.make({ key: keyLens, f: ([, id]) => HttpClient.HttpClient.pipe( - Effect.tap(Effect.sleep("1 second")), - Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), - Effect.andThen(response => response.json), - Effect.andThen(Schema.decodeUnknownEffect(Post)), + Effect.tap(Effect.sleep("500 millis")), + Effect.flatMap(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), + Effect.flatMap(response => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(Post)), ), staleTime: "10 seconds", - }) + }).pipe( + Query.run, + ) const mutation = yield* Mutation.make({ f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(