Finalize the Effect View rename and refresh docs, examples, and tooling #59

Merged
Thilawyn merged 12 commits from next into master 2026-07-27 04:11:42 +02:00
88 changed files with 763 additions and 763 deletions
Showing only changes of commit 38320b502d - Show all commits
+50
View File
@@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
@@ -1,10 +1,9 @@
{ {
"name": "@effect-fc/example-next", "name": "@effect-fc/example",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc -b && vite build",
"dev": "vite", "dev": "vite",
"lint:tsc": "tsc -b --noEmit", "lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint", "lint:biome": "biome lint",
@@ -14,7 +13,6 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect-view/vite-plugin": "workspace:*",
"@tanstack/react-router": "^1.170.10", "@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13", "@tanstack/router-plugin": "^1.168.13",
@@ -24,18 +22,21 @@
"globals": "^17.6.0", "globals": "^17.6.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"type-fest": "^5.7.0",
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform-browser": "4.0.0-beta.98", "@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"effect": "4.0.0-beta.98", "@typed/id": "^0.17.2",
"effect-fc-next": "workspace:*", "effect": "^3.21.2",
"effect-fc": "workspace:*",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"effect": "4.0.0-beta.98", "effect": "^3.21.2",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-fc-next" import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useInput(props.form, props) const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([ const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={isCommitting} disabled={isCommitting}
{...Struct.omit(props, ["form"])} {...Struct.omit(props, "form")}
> >
{isValidating && {isValidating &&
<TextField.Slot side="right"> <TextField.Slot side="right">
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, View } from "effect-fc-next" import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useOptionalInput(props.form, props) const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* View.useAll([ const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting} disabled={!input.enabled || isCommitting}
{...Struct.omit(props, ["form", "defaultValue"])} {...Struct.omit(props, "form", "defaultValue")}
> >
<TextField.Slot side="left"> <TextField.Slot side="left">
<Switch <Switch
@@ -1,21 +1,24 @@
import { createRouter, RouterProvider } from "@tanstack/react-router" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactRuntime } from "effect-fc-next" import { ReactRuntime } from "effect-fc"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
import { runtime } from "./runtime" import { runtime } from "./runtime"
const router = createRouter({ routeTree }) const router = createRouter({ routeTree })
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { router: typeof router } interface Register {
router: typeof router
}
} }
// biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element // biome-ignore lint/style/noNonNullAssertion: React entrypoint
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ReactRuntime.Provider runtime={runtime}> <ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ReactRuntime.Provider> </ReactRuntime.Provider>
</StrictMode>, </StrictMode>
) )
@@ -9,26 +9,23 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index' import { Route as ResultRouteImport } from './routes/result'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form'
import { Route as LensformRouteImport } from './routes/lensform'
import { Route as QueryRouteImport } from './routes/query' import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context'
const IndexRoute = IndexRouteImport.update({ const ResultRoute = ResultRouteImport.update({
id: '/', id: '/result',
path: '/', path: '/result',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AsyncRoute = AsyncRouteImport.update({ const QueryRoute = QueryRouteImport.update({
id: '/async', id: '/query',
path: '/async', path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const FormRoute = FormRouteImport.update({ const FormRoute = FormRouteImport.update({
@@ -36,14 +33,29 @@ const FormRoute = FormRouteImport.update({
path: '/form', path: '/form',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LensformRoute = LensformRouteImport.update({ const BlankRoute = BlankRouteImport.update({
id: '/lensform', id: '/blank',
path: '/lensform', path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const QueryRoute = QueryRouteImport.update({ const AsyncRoute = AsyncRouteImport.update({
id: '/query', id: '/async',
path: '/query', path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DevMemoRoute = DevMemoRouteImport.update({
id: '/dev/memo',
path: '/dev/memo',
getParentRoute: () => rootRouteImport,
} as any)
const DevContextRoute = DevContextRouteImport.update({
id: '/dev/context',
path: '/dev/context',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
@@ -52,16 +64,20 @@ export interface FileRoutesByFullPath {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -69,15 +85,42 @@ export interface FileRoutesById {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' fullPaths:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' to:
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query' | '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -85,31 +128,26 @@ export interface RootRouteChildren {
AsyncRoute: typeof AsyncRoute AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
LensformRoute: typeof LensformRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute
DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/': { '/result': {
id: '/' id: '/result'
path: '/' path: '/result'
fullPath: '/' fullPath: '/result'
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof ResultRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/async': { '/query': {
id: '/async' id: '/query'
path: '/async' path: '/query'
fullPath: '/async' fullPath: '/query'
preLoaderRoute: typeof AsyncRouteImport preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/form': { '/form': {
@@ -119,18 +157,39 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof FormRouteImport preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/lensform': { '/blank': {
id: '/lensform' id: '/blank'
path: '/lensform' path: '/blank'
fullPath: '/lensform' fullPath: '/blank'
preLoaderRoute: typeof LensformRouteImport preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/query': { '/async': {
id: '/query' id: '/async'
path: '/query' path: '/async'
fullPath: '/query' fullPath: '/async'
preLoaderRoute: typeof QueryRouteImport preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/memo': {
id: '/dev/memo'
path: '/dev/memo'
fullPath: '/dev/memo'
preLoaderRoute: typeof DevMemoRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/context': {
id: '/dev/context'
path: '/dev/context'
fullPath: '/dev/context'
preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
} }
@@ -141,8 +200,10 @@ const rootRouteChildren: RootRouteChildren = {
AsyncRoute: AsyncRoute, AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
LensformRoute: LensformRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
@@ -7,7 +7,7 @@ import "../index.css"
export const Route = createRootRoute({ export const Route = createRootRoute({
component: Root, component: Root
}) })
function Root() { function Root() {
@@ -17,10 +17,6 @@ function Root() {
<Flex direction="row" justify="center" align="center" gap="2"> <Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link> <Link to="/">Index</Link>
<Link to="/blank">Blank</Link> <Link to="/blank">Blank</Link>
<Link to="/async">Async</Link>
<Link to="/query">Query</Link>
<Link to="/form">Form</Link>
<Link to="/lensform">LensForm</Link>
</Flex> </Flex>
</Container> </Container>
@@ -1,35 +1,30 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect } from "effect" import { Array, Effect, flow, Option, Schema } from "effect"
import { Async, Component, Memoized } from "effect-fc-next" import { Async, Component, Memoized } from "effect-fc"
import * as React from "react" import * as React from "react"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const fetchPost = (id: number) => Effect.sleep("500 millis").pipe( const Post = Schema.Struct({
Effect.as({ userId: Schema.Int,
title: `Post ${id}`, id: Schema.Int,
body: `This is the content of post ${id}.`, title: Schema.String,
}), body: Schema.String,
) })
interface AsyncFetchPostViewProps { interface AsyncFetchPostViewProps {
readonly id: number readonly id: number
} }
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*( class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
props: AsyncFetchPostViewProps, const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
) { Effect.tap(Effect.sleep("500 millis")),
yield* Component.useOnMount(() => Effect.gen(function*() { Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted")) Effect.andThen(response => response.json),
yield* Console.log("AsyncFetchPostView mounted") Effect.andThen(Schema.decodeUnknown(Post)),
})) ), [props.id])
const post = yield* Component.useOnChange(
() => fetchPost(props.id),
[props.id],
)
return ( return (
<div> <div>
@@ -39,9 +34,10 @@ const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
) )
}).pipe( }).pipe(
Async.async, Async.async,
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }), Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Memoized.memoized, Memoized.memoized,
) ) {}
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
@@ -54,22 +50,20 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
<TextField.Root <TextField.Root
value={text} value={text}
onChange={event => setText(event.currentTarget.value)} onChange={e => setText(e.currentTarget.value)}
/> />
<Slider <Slider
value={[id]} value={[id]}
min={1} onValueChange={flow(Array.head, Option.getOrThrow, setId)}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/> />
<AsyncFetchPost id={id} /> <AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex> </Flex>
</Container> </Container>
) )
}).pipe( }).pipe(
Component.withContext(runtime.context), Component.withRuntime(runtime.context)
) )
export const Route = createFileRoute("/async")({ export const Route = createFileRoute("/async")({
@@ -0,0 +1,143 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
{
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)
const RegisterFormSchema = Schema.Struct({
email: Schema.String.pipe(email),
password: Schema.String.pipe(Schema.minLength(3)),
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
})
const RegisterFormSubmitSchema = Schema.Struct({
email: Schema.transformOrFail(
Schema.String,
Schema.String,
{
decode: (input, _options, ast) => input !== "admin@admin.com"
? ParseResult.succeed(input)
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
encode: ParseResult.succeed,
},
),
password: Schema.String,
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
})
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}),
})
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
})
}) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService
const [canCommit, submitResult] = yield* Subscribable.useAll([
form.form.canCommit,
form.form.mutation.result,
])
const runPromise = yield* Component.useRunPromise()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
yield* Console.log("RegisterFormView mounted")
}))
return (
<Container width="300">
<form onSubmit={e => {
e.preventDefault()
void runPromise(form.form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
form={form.emailField}
debounce="250 millis"
/>
<TextFieldFormInput
form={form.passwordField}
debounce="250 millis"
/>
<TextFieldOptionalFormInput
type="datetime-local"
form={form.birthField}
defaultValue=""
/>
<Button disabled={!canCommit}>Submit</Button>
</Flex>
</form>
{Match.value(submitResult).pipe(
Match.tag("Initial", () => <></>),
Match.tag("Running", () => <Text>Submitting...</Text>),
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
Match.exhaustive,
)}
</Container>
)
}) {}
const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterForm = yield* Effect.provide(
RegisterFormView.use,
yield* Component.useLayer(RegisterFormService.Default),
)
return <RegisterForm />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/form")({
component: RegisterPage
})
@@ -0,0 +1,24 @@
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { runtime } from "@/runtime"
import { TodosState } from "@/todo/TodosState"
import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.make("IndexView")(function*() {
const Todos = yield* Effect.provide(
TodosView.use,
yield* Component.useLayer(TodosStateLive),
)
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/")({
component: Index
})
@@ -0,0 +1,117 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({
key,
f: ([id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
),
})
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
Effect.andThen(Stream.fromQueue),
Stream.unwrapScoped,
Stream.runForEach(flow(
Cause.failures,
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
Option.match({
onSome: e => Console.log("ResultView HttpClient error", e),
onNone: () => Effect.void,
}),
)),
Effect.forkScoped,
))
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<div>
{Match.value(queryResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
</Flex>
<div>
{Match.value(mutationResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
</Flex>
</Flex>
</Container>
)
})
export const Route = createFileRoute("/query")({
component: Component.withRuntime(ResultView, runtime.context)
})
@@ -1,11 +1,10 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect" import { DateTime, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { ReactRuntime } from "effect-fc"
import { QueryClient, ReactRuntime } from "effect-fc-next"
export const layer = Layer.empty.pipe( export const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer()),
Layer.provideMerge(DateTime.layerCurrentZoneLocal), Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer), Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer), Layer.provideMerge(Geolocation.layer),
@@ -13,4 +12,4 @@ export const layer = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(FetchHttpClient.layer),
) )
export const runtime = ReactRuntime.make(layer) export const runtime = ReactRuntime.make(AppLive)
@@ -1,6 +1,5 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite" import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import { effectViewPlugin } from "@effect-view/vite-plugin"
import path from "node:path" import path from "node:path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
@@ -8,7 +7,6 @@ import { defineConfig } from "vite"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
effectViewPlugin(),
tanstackRouter({ tanstackRouter({
target: "react", target: "react",
autoCodeSplitting: true, autoCodeSplitting: true,
@@ -1,7 +1,7 @@
{ {
"name": "effect-fc-next", "name": "effect-view",
"description": "Write React function components with Effect", "description": "Write React function components with Effect",
"version": "0.1.0-beta.0", "version": "0.1.0",
"type": "module", "type": "module",
"files": [ "files": [
"./README.md", "./README.md",
-10
View File
@@ -1,10 +0,0 @@
# Effect FC Next Example
Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`.
```bash
bun run dev
```
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
rendered through an Effect-FC component.
-82
View File
@@ -1,82 +0,0 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Schema } from "effect"
import { Component, Form, MutationForm, View } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { runtime } from "@/runtime"
const RegisterSchema = Schema.Struct({
email: Schema.String.check(
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
message: "Enter a valid email address",
}),
),
password: Schema.String.check(
Schema.isMinLength(5, {
message: "Password must be at least 5 characters long",
}),
),
})
const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("Form route unmounted"))
yield* Console.log("Form route mounted")
}))
const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* MutationForm.service({
schema: RegisterSchema,
initialEncodedValue: { email: "", password: "" },
f: ([value]) => Effect.log(`Registered ${value.email}`),
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
return [form, emailField, passwordField] as const
}))
const [canCommit, isCommitting] = yield* View.useAll([
form.canCommit,
form.isCommitting,
])
const TextFieldFormInput = yield* TextFieldFormInputView.use
const runPromise = yield* Component.useRunPromise()
return (
<Container width="300">
<form onSubmit={event => {
event.preventDefault()
void runPromise(form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
form={emailField}
placeholder="Email"
debounce="250 millis"
/>
<TextFieldFormInput
form={passwordField}
placeholder="Password"
type="password"
debounce="250 millis"
/>
<Button disabled={!canCommit || isCommitting}>
{isCommitting ? "Submitting…" : "Submit"}
</Button>
</Flex>
</form>
<Text size="2">A MutationForm validates local input, then submits it.</Text>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/form")({
component: RegisterRouteComponent,
})
@@ -1,56 +0,0 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
const todosLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make<readonly string[]>([]),
Lens.fromSubscriptionRef,
))
const draftLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(""),
Lens.fromSubscriptionRef,
))
const [todos] = yield* View.useAll([todosLens])
const [draft, setDraft] = yield* Lens.useState(draftLens)
const runPromise = yield* Component.useRunPromise()
const addTodo = Lens.update(todosLens, todos =>
draft.trim() === ""
? todos
: [...todos, draft.trim()],
).pipe(
Effect.andThen(Lens.set(draftLens, "")),
)
return (
<Container width="480">
<Flex direction="column" gap="3">
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
<Flex gap="2">
<TextField.Root
value={draft}
onChange={event => setDraft(event.currentTarget.value)}
/>
<Button onClick={() => void runPromise(addTodo)}>
Add
</Button>
</Flex>
{todos.map(todo => <Text key={todo}> {todo}</Text>)}
</Flex>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/")({
component: TodoRouteComponent,
})
-102
View File
@@ -1,102 +0,0 @@
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, Schema, SubscriptionRef } from "effect"
import { HttpClient } from "effect/unstable/http"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface PostResultViewProps {
readonly result: AsyncResult.AsyncResult<typeof Post.Type, Error>
}
const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, {
onInitial: result => result.waiting
? <Text>Loading...</Text>
: <Text>No data.</Text>,
onFailure: result => <Text>Request failed: { result.cause.toString() }</Text>,
onSuccess: result => <>
{result.waiting && <Text>Refreshing...</Text>}
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>,
})
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(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({
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)),
),
staleTime: "10 seconds",
})
const mutation = yield* Mutation.make({
f: ([id]: [id: number]) => 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)),
),
})
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* Lens.useState(idLens)
const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state])
const runSync = yield* Component.useRunSync()
return (
<Container>
<Flex direction="column" align="center" gap="2">
<Slider
value={[id]}
min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/>
<PostResultView result={queryState.result} />
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runSync(query.refreshView)}>
Refresh
</Button>
<Button onClick={() => runSync(query.invalidateCache)}>
Invalidate cache
</Button>
</Flex>
<PostResultView result={mutationState} />
<Button onClick={() => runSync(mutation.mutateView([id]))}>
Mutate
</Button>
</Flex>
</Container>
)
}).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/query")({
component: QueryRouteComponent,
})
+6 -46
View File
@@ -1,50 +1,10 @@
# React + TypeScript + Vite # Effect FC Next Example
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Minimal React example for `effect-fc-next`, Effect V4, and `effect-lens@2`.
Currently, two official plugins are available: ```bash
bun run dev
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
``` ```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
- Optionally add `...tseslint.configs.stylisticTypeChecked` rendered through an Effect-FC component.
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
+7 -8
View File
@@ -1,9 +1,10 @@
{ {
"name": "@effect-fc/example", "name": "@effect-view/example",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc -b && vite build",
"dev": "vite", "dev": "vite",
"lint:tsc": "tsc -b --noEmit", "lint:tsc": "tsc -b --noEmit",
"lint:biome": "biome lint", "lint:biome": "biome lint",
@@ -13,6 +14,7 @@
"clean:modules": "rm -rf node_modules" "clean:modules": "rm -rf node_modules"
}, },
"devDependencies": { "devDependencies": {
"@effect-view/vite-plugin": "workspace:*",
"@tanstack/react-router": "^1.170.10", "@tanstack/react-router": "^1.170.10",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/router-plugin": "^1.168.13", "@tanstack/router-plugin": "^1.168.13",
@@ -22,21 +24,18 @@
"globals": "^17.6.0", "globals": "^17.6.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"type-fest": "^5.7.0",
"vite": "^8.0.16" "vite": "^8.0.16"
}, },
"dependencies": { "dependencies": {
"@effect/platform": "^0.96.1", "@effect/platform-browser": "4.0.0-beta.98",
"@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.3.0", "@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2", "effect": "4.0.0-beta.98",
"effect": "^3.21.2", "effect-fc-next": "workspace:*",
"effect-fc": "workspace:*",
"react-icons": "^5.6.0" "react-icons": "^5.6.0"
}, },
"overrides": { "overrides": {
"@types/react": "^19.2.15", "@types/react": "^19.2.15",
"effect": "^3.21.2", "effect": "4.0.0-beta.98",
"react": "^19.2.6" "react": "^19.2.6"
} }
} }
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, View } from "effect-fc-next"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useInput(props.form, props) const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={isCommitting} disabled={isCommitting}
{...Struct.omit(props, "form")} {...Struct.omit(props, ["form"])}
> >
{isValidating && {isValidating &&
<TextField.Slot side="right"> <TextField.Slot side="right">
@@ -1,6 +1,6 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect" import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc" import { Component, Form, View } from "effect-fc-next"
import type * as React from "react" import type * as React from "react"
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any> props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) { ) {
const input = yield* Form.useOptionalInput(props.form, props) const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([ const [issues, isValidating, isCommitting] = yield* View.useAll([
props.form.issues, props.form.issues,
props.form.isValidating, props.form.isValidating,
props.form.isCommitting, props.form.isCommitting,
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
value={input.value} value={input.value}
onChange={e => input.setValue(e.target.value)} onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isCommitting} disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")} {...Struct.omit(props, ["form", "defaultValue"])}
> >
<TextField.Slot side="left"> <TextField.Slot side="left">
<Switch <Switch
+4 -7
View File
@@ -1,24 +1,21 @@
import { createRouter, RouterProvider } from "@tanstack/react-router" import { createRouter, RouterProvider } from "@tanstack/react-router"
import { ReactRuntime } from "effect-fc" import { ReactRuntime } from "effect-fc-next"
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
import { runtime } from "./runtime" import { runtime } from "./runtime"
const router = createRouter({ routeTree }) const router = createRouter({ routeTree })
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register { router: typeof router }
router: typeof router
}
} }
// biome-ignore lint/style/noNonNullAssertion: React entrypoint // biome-ignore lint/style/noNonNullAssertion: the Vite template provides this element
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ReactRuntime.Provider runtime={runtime}> <ReactRuntime.Provider runtime={runtime}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ReactRuntime.Provider> </ReactRuntime.Provider>
</StrictMode> </StrictMode>,
) )
+57 -118
View File
@@ -9,33 +9,16 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo' import { Route as AsyncRouteImport } from './routes/async'
import { Route as DevContextRouteImport } from './routes/dev/context' import { Route as BlankRouteImport } from './routes/blank'
import { Route as FormRouteImport } from './routes/form'
import { Route as LensformRouteImport } from './routes/lensform'
import { Route as QueryRouteImport } from './routes/query'
const ResultRoute = ResultRouteImport.update({ const IndexRoute = IndexRouteImport.update({
id: '/result', id: '/',
path: '/result', path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport,
} as any)
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const AsyncRoute = AsyncRouteImport.update({ const AsyncRoute = AsyncRouteImport.update({
@@ -43,19 +26,24 @@ const AsyncRoute = AsyncRouteImport.update({
path: '/async', path: '/async',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const IndexRoute = IndexRouteImport.update({ const BlankRoute = BlankRouteImport.update({
id: '/', id: '/blank',
path: '/', path: '/blank',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DevMemoRoute = DevMemoRouteImport.update({ const FormRoute = FormRouteImport.update({
id: '/dev/memo', id: '/form',
path: '/dev/memo', path: '/form',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const DevContextRoute = DevContextRouteImport.update({ const LensformRoute = LensformRouteImport.update({
id: '/dev/context', id: '/lensform',
path: '/dev/context', path: '/lensform',
getParentRoute: () => rootRouteImport,
} as any)
const QueryRoute = QueryRouteImport.update({
id: '/query',
path: '/query',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
@@ -64,20 +52,16 @@ export interface FileRoutesByFullPath {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -85,42 +69,15 @@ export interface FileRoutesById {
'/async': typeof AsyncRoute '/async': typeof AsyncRoute
'/blank': typeof BlankRoute '/blank': typeof BlankRoute
'/form': typeof FormRoute '/form': typeof FormRoute
'/lensform': typeof LensformRoute
'/query': typeof QueryRoute '/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
| '/' id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/context'
| '/dev/memo'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -128,40 +85,17 @@ export interface RootRouteChildren {
AsyncRoute: typeof AsyncRoute AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute FormRoute: typeof FormRoute
LensformRoute: typeof LensformRoute
QueryRoute: typeof QueryRoute QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute
DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/result': { '/': {
id: '/result' id: '/'
path: '/result' path: '/'
fullPath: '/result' fullPath: '/'
preLoaderRoute: typeof ResultRouteImport preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport
}
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
fullPath: '/blank'
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/async': { '/async': {
@@ -171,25 +105,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AsyncRouteImport preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/': { '/blank': {
id: '/' id: '/blank'
path: '/' path: '/blank'
fullPath: '/' fullPath: '/blank'
preLoaderRoute: typeof IndexRouteImport preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/memo': { '/form': {
id: '/dev/memo' id: '/form'
path: '/dev/memo' path: '/form'
fullPath: '/dev/memo' fullPath: '/form'
preLoaderRoute: typeof DevMemoRouteImport preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/dev/context': { '/lensform': {
id: '/dev/context' id: '/lensform'
path: '/dev/context' path: '/lensform'
fullPath: '/dev/context' fullPath: '/lensform'
preLoaderRoute: typeof DevContextRouteImport preLoaderRoute: typeof LensformRouteImport
parentRoute: typeof rootRouteImport
}
'/query': {
id: '/query'
path: '/query'
fullPath: '/query'
preLoaderRoute: typeof QueryRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
} }
@@ -200,10 +141,8 @@ const rootRouteChildren: RootRouteChildren = {
AsyncRoute: AsyncRoute, AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute, BlankRoute: BlankRoute,
FormRoute: FormRoute, FormRoute: FormRoute,
LensformRoute: LensformRoute,
QueryRoute: QueryRoute, QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+5 -1
View File
@@ -7,7 +7,7 @@ import "../index.css"
export const Route = createRootRoute({ export const Route = createRootRoute({
component: Root component: Root,
}) })
function Root() { function Root() {
@@ -17,6 +17,10 @@ function Root() {
<Flex direction="row" justify="center" align="center" gap="2"> <Flex direction="row" justify="center" align="center" gap="2">
<Link to="/">Index</Link> <Link to="/">Index</Link>
<Link to="/blank">Blank</Link> <Link to="/blank">Blank</Link>
<Link to="/async">Async</Link>
<Link to="/query">Query</Link>
<Link to="/form">Form</Link>
<Link to="/lensform">LensForm</Link>
</Flex> </Flex>
</Container> </Container>
+29 -23
View File
@@ -1,30 +1,35 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes" import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect" import { Console, Effect } from "effect"
import { Async, Component, Memoized } from "effect-fc" import { Async, Component, Memoized } from "effect-fc-next"
import * as React from "react" import * as React from "react"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const Post = Schema.Struct({ const fetchPost = (id: number) => Effect.sleep("500 millis").pipe(
userId: Schema.Int, Effect.as({
id: Schema.Int, title: `Post ${id}`,
title: Schema.String, body: `This is the content of post ${id}.`,
body: Schema.String, }),
}) )
interface AsyncFetchPostViewProps { interface AsyncFetchPostViewProps {
readonly id: number readonly id: number
} }
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) { const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe( props: AsyncFetchPostViewProps,
Effect.tap(Effect.sleep("500 millis")), ) {
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)), yield* Component.useOnMount(() => Effect.gen(function*() {
Effect.andThen(response => response.json), yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted"))
Effect.andThen(Schema.decodeUnknown(Post)), yield* Console.log("AsyncFetchPostView mounted")
), [props.id]) }))
const post = yield* Component.useOnChange(
() => fetchPost(props.id),
[props.id],
)
return ( return (
<div> <div>
@@ -34,10 +39,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(
) )
}).pipe( }).pipe(
Async.async, Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }), Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
Memoized.memoized, Memoized.memoized,
) {} )
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post") const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
@@ -50,20 +54,22 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
<TextField.Root <TextField.Root
value={text} value={text}
onChange={e => setText(e.currentTarget.value)} onChange={event => setText(event.currentTarget.value)}
/> />
<Slider <Slider
value={[id]} value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)} min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/> />
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} /> <AsyncFetchPost id={id} />
</Flex> </Flex>
</Container> </Container>
) )
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withContext(runtime.context),
) )
export const Route = createFileRoute("/async")({ export const Route = createFileRoute("/async")({
+48 -109
View File
@@ -1,143 +1,82 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes" import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect" import { Console, Effect, Schema } from "effect"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc" import { Component, Form, MutationForm, View } from "effect-fc-next"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView" import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>( const RegisterSchema = Schema.Struct({
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i, email: Schema.String.check(
Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {
{ message: "Enter a valid email address",
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)
const RegisterFormSchema = Schema.Struct({
email: Schema.String.pipe(email),
password: Schema.String.pipe(Schema.minLength(3)),
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
})
const RegisterFormSubmitSchema = Schema.Struct({
email: Schema.transformOrFail(
Schema.String,
Schema.String,
{
decode: (input, _options, ast) => input !== "admin@admin.com"
? ParseResult.succeed(input)
: ParseResult.fail(new ParseResult.Type(ast, input, "This email is already in use.")),
encode: ParseResult.succeed,
},
),
password: Schema.String,
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
})
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() },
f: Effect.fnUntraced(function*([value]) {
yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}), }),
),
password: Schema.String.check(
Schema.isMinLength(5, {
message: "Password must be at least 5 characters long",
}),
),
}) })
return { const RegisterRouteComponent = Component.make("RegisterRouteView")(function*() {
form, yield* Component.useOnMount(() => Effect.gen(function*() {
emailField: Form.focusObjectOn(form, "email"), yield* Effect.addFinalizer(() => Console.log("Form route unmounted"))
passwordField: Form.focusObjectOn(form, "password"), yield* Console.log("Form route mounted")
birthField: Form.focusObjectOn(form, "birth"), }))
} as const
})
}) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() { const [form, emailField, passwordField] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* RegisterFormService const form = yield* MutationForm.service({
const [canCommit, submitResult] = yield* Subscribable.useAll([ schema: RegisterSchema,
form.form.canCommit, initialEncodedValue: { email: "", password: "" },
form.form.mutation.result, f: ([value]) => Effect.log(`Registered ${value.email}`),
})
const emailField = Form.focusObjectOn(form, "email")
const passwordField = Form.focusObjectOn(form, "password")
return [form, emailField, passwordField] as const
}))
const [canCommit, isCommitting] = yield* View.useAll([
form.canCommit,
form.isCommitting,
]) ])
const runPromise = yield* Component.useRunPromise()
const TextFieldFormInput = yield* TextFieldFormInputView.use const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use const runPromise = yield* Component.useRunPromise()
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
yield* Console.log("RegisterFormView mounted")
}))
return ( return (
<Container width="300"> <Container width="300">
<form onSubmit={e => { <form onSubmit={event => {
e.preventDefault() event.preventDefault()
void runPromise(form.form.submit) void runPromise(form.submit)
}}> }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<TextFieldFormInput <TextFieldFormInput
form={form.emailField} form={emailField}
placeholder="Email"
debounce="250 millis" debounce="250 millis"
/> />
<TextFieldFormInput <TextFieldFormInput
form={form.passwordField} form={passwordField}
placeholder="Password"
type="password"
debounce="250 millis" debounce="250 millis"
/> />
<Button disabled={!canCommit || isCommitting}>
<TextFieldOptionalFormInput {isCommitting ? "Submitting…" : "Submit"}
type="datetime-local" </Button>
form={form.birthField}
defaultValue=""
/>
<Button disabled={!canCommit}>Submit</Button>
</Flex> </Flex>
</form> </form>
<Text size="2">A MutationForm validates local input, then submits it.</Text>
{Match.value(submitResult).pipe(
Match.tag("Initial", () => <></>),
Match.tag("Running", () => <Text>Submitting...</Text>),
Match.tag("Success", () => <Text>Submitted successfully!</Text>),
Match.tag("Failure", e => <Text>Error: {e.cause.toString()}</Text>),
Match.exhaustive,
)}
</Container> </Container>
) )
}) {}
const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterForm = yield* Effect.provide(
RegisterFormView.use,
yield* Component.useLayer(RegisterFormService.Default),
)
return <RegisterForm />
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withContext(runtime.context),
) )
export const Route = createFileRoute("/form")({ export const Route = createFileRoute("/form")({
component: RegisterPage component: RegisterRouteComponent,
}) })
+44 -12
View File
@@ -1,24 +1,56 @@
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect" import { Effect, SubscriptionRef } from "effect"
import { Component } from "effect-fc" import { Component, Lens, View } from "effect-fc-next"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
import { TodosState } from "@/todo/TodosState"
import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos") const TodoRouteComponent = Component.make("TodoRouteView")(function*() {
const todosLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make<readonly string[]>([]),
Lens.fromSubscriptionRef,
))
const draftLens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(""),
Lens.fromSubscriptionRef,
))
const Index = Component.make("IndexView")(function*() { const [todos] = yield* View.useAll([todosLens])
const Todos = yield* Effect.provide( const [draft, setDraft] = yield* Lens.useState(draftLens)
TodosView.use, const runPromise = yield* Component.useRunPromise()
yield* Component.useLayer(TodosStateLive),
const addTodo = Lens.update(todosLens, todos =>
draft.trim() === ""
? todos
: [...todos, draft.trim()],
).pipe(
Effect.andThen(Lens.set(draftLens, "")),
) )
return <Todos /> return (
<Container width="480">
<Flex direction="column" gap="3">
<Text size="2">A small Effect v4 todo state example backed by a Lens.</Text>
<Flex gap="2">
<TextField.Root
value={draft}
onChange={event => setDraft(event.currentTarget.value)}
/>
<Button onClick={() => void runPromise(addTodo)}>
Add
</Button>
</Flex>
{todos.map(todo => <Text key={todo}> {todo}</Text>)}
</Flex>
</Container>
)
}).pipe( }).pipe(
Component.withRuntime(runtime.context) Component.withContext(runtime.context),
) )
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: Index component: TodoRouteComponent,
}) })
+50 -65
View File
@@ -1,8 +1,9 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect" import { Effect, Schema, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc" import { HttpClient } from "effect/unstable/http"
import { AsyncResult } from "effect/unstable/reactivity"
import { Component, Lens, Mutation, Query, View } from "effect-fc-next"
import { runtime } from "@/runtime" import { runtime } from "@/runtime"
@@ -13,30 +14,45 @@ const Post = Schema.Struct({
body: Schema.String, body: Schema.String,
}) })
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
interface PostResultViewProps {
readonly result: AsyncResult.AsyncResult<typeof Post.Type, Error>
}
const PostResultView = (props: PostResultViewProps) => AsyncResult.match(props.result, {
onInitial: result => result.waiting
? <Text>Loading...</Text>
: <Text>No data.</Text>,
onFailure: result => <Text>Request failed: { result.cause.toString() }</Text>,
onSuccess: result => <>
{result.waiting && <Text>Refreshing...</Text>}
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
</>,
})
const QueryRouteComponent = Component.make("QueryRouteView")(function*() {
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() { const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1)) const keyLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const))
const key = Stream.map(idLens.changes, id => [id] as const) const idLens = Lens.focusTupleAt(keyLens, 1)
const query = yield* Query.service({ const query = yield* Query.service({
key, key: keyLens,
f: ([id]) => HttpClient.HttpClient.pipe( f: ([, id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")), Effect.tap(Effect.sleep("1 second")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
staleTime: "10 seconds", staleTime: "10 seconds",
}) })
const mutation = yield* Mutation.make({ const mutation = yield* Mutation.make({
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe( f: ([id]: [id: number]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")), Effect.tap(Effect.sleep("1 second")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json), Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)), Effect.andThen(Schema.decodeUnknownEffect(Post)),
), ),
}) })
@@ -44,74 +60,43 @@ const ResultView = Component.make("ResultView")(function*() {
})) }))
const [id, setId] = yield* Lens.useState(idLens) const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result]) const [queryState, mutationState] = yield* View.useAll([query.state, mutation.state])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe( const runSync = yield* Component.useRunSync()
Effect.andThen(observer => observer.subscribe),
Effect.andThen(Stream.fromQueue),
Stream.unwrapScoped,
Stream.runForEach(flow(
Cause.failures,
Chunk.findFirst(e => e._tag === "RequestError" || e._tag === "ResponseError"),
Option.match({
onSome: e => Console.log("ResultView HttpClient error", e),
onNone: () => Effect.void,
}),
)),
Effect.forkScoped,
))
return ( return (
<Container> <Container>
<Flex direction="column" align="center" gap="2"> <Flex direction="column" align="center" gap="2">
<Slider <Slider
value={[id]} value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)} min={1}
max={10}
onValueChange={([value]) => setId(value ?? 1)}
/> />
<div> <PostResultView result={queryState.result} />
{Match.value(queryResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1"> <Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button> <Button onClick={() => runSync(query.refreshView)}>
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button> Refresh
</Button>
<Button onClick={() => runSync(query.invalidateCache)}>
Invalidate cache
</Button>
</Flex> </Flex>
<div> <PostResultView result={mutationState} />
{Match.value(mutationResult).pipe(
Match.tag("Running", () => <Text>Loading...</Text>),
Match.tag("Success", result => <>
<Heading>{result.value.title}</Heading>
<Text>{result.value.body}</Text>
{Result.hasRefreshingFlag(result) && <Text>Refreshing...</Text>}
</>),
Match.tag("Failure", result =>
<Text>An error has occured: {result.cause.toString()}</Text>
),
Match.orElse(() => <></>),
)}
</div>
<Flex direction="row" justify="center" align="center" gap="1"> <Button onClick={() => runSync(mutation.mutateView([id]))}>
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button> Mutate
</Flex> </Button>
</Flex> </Flex>
</Container> </Container>
) )
}) }).pipe(
Component.withContext(runtime.context),
)
export const Route = createFileRoute("/query")({ export const Route = createFileRoute("/query")({
component: Component.withRuntime(ResultView, runtime.context) component: QueryRouteComponent,
}) })
+5 -4
View File
@@ -1,10 +1,11 @@
import { FetchHttpClient } from "@effect/platform"
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser" import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
import { DateTime, Layer } from "effect" import { DateTime, Layer } from "effect"
import { ReactRuntime } from "effect-fc" import { FetchHttpClient } from "effect/unstable/http"
import { QueryClient, ReactRuntime } from "effect-fc-next"
export const AppLive = Layer.empty.pipe( export const layer = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer()),
Layer.provideMerge(DateTime.layerCurrentZoneLocal), Layer.provideMerge(DateTime.layerCurrentZoneLocal),
Layer.provideMerge(Clipboard.layer), Layer.provideMerge(Clipboard.layer),
Layer.provideMerge(Geolocation.layer), Layer.provideMerge(Geolocation.layer),
@@ -12,4 +13,4 @@ export const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(FetchHttpClient.layer),
) )
export const runtime = ReactRuntime.make(AppLive) export const runtime = ReactRuntime.make(layer)
+2
View File
@@ -1,5 +1,6 @@
import { tanstackRouter } from "@tanstack/router-plugin/vite" import { tanstackRouter } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import { effectViewPlugin } from "@effect-view/vite-plugin"
import path from "node:path" import path from "node:path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
@@ -7,6 +8,7 @@ import { defineConfig } from "vite"
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
effectViewPlugin(),
tanstackRouter({ tanstackRouter({
target: "react", target: "react",
autoCodeSplitting: true, autoCodeSplitting: true,