@@ -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",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
@@ -14,7 +13,6 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect-view/vite-plugin": "workspace:*",
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
@@ -24,18 +22,21 @@
|
||||
"globals": "^17.6.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"type-fest": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"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",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect-fc-next": "workspace:*",
|
||||
"@typed/id": "^0.17.2",
|
||||
"effect": "^3.21.2",
|
||||
"effect-fc": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect": "^3.21.2",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
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"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
||||
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||
) {
|
||||
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.isValidating,
|
||||
props.form.isCommitting,
|
||||
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, ["form"])}
|
||||
{...Struct.omit(props, "form")}
|
||||
>
|
||||
{isValidating &&
|
||||
<TextField.Slot side="right">
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||
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"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
||||
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||
) {
|
||||
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.isValidating,
|
||||
props.form.isCommitting,
|
||||
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={!input.enabled || isCommitting}
|
||||
{...Struct.omit(props, ["form", "defaultValue"])}
|
||||
{...Struct.omit(props, "form", "defaultValue")}
|
||||
>
|
||||
<TextField.Slot side="left">
|
||||
<Switch
|
||||
@@ -1,21 +1,24 @@
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReactRuntime } from "effect-fc-next"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
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(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
)
|
||||
+118
-57
@@ -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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
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 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 DevMemoRouteImport } from './routes/dev/memo'
|
||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
const ResultRoute = ResultRouteImport.update({
|
||||
id: '/result',
|
||||
path: '/result',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AsyncRoute = AsyncRouteImport.update({
|
||||
id: '/async',
|
||||
path: '/async',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlankRoute = BlankRouteImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const FormRoute = FormRouteImport.update({
|
||||
@@ -36,14 +33,29 @@ const FormRoute = FormRouteImport.update({
|
||||
path: '/form',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LensformRoute = LensformRouteImport.update({
|
||||
id: '/lensform',
|
||||
path: '/lensform',
|
||||
const BlankRoute = BlankRouteImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
const AsyncRoute = AsyncRouteImport.update({
|
||||
id: '/async',
|
||||
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,
|
||||
} as any)
|
||||
|
||||
@@ -52,16 +64,20 @@ export interface FileRoutesByFullPath {
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -69,15 +85,42 @@ export interface FileRoutesById {
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
to:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -85,31 +128,26 @@ export interface RootRouteChildren {
|
||||
AsyncRoute: typeof AsyncRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
FormRoute: typeof FormRoute
|
||||
LensformRoute: typeof LensformRoute
|
||||
QueryRoute: typeof QueryRoute
|
||||
ResultRoute: typeof ResultRoute
|
||||
DevContextRoute: typeof DevContextRoute
|
||||
DevMemoRoute: typeof DevMemoRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
'/result': {
|
||||
id: '/result'
|
||||
path: '/result'
|
||||
fullPath: '/result'
|
||||
preLoaderRoute: typeof ResultRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/async': {
|
||||
id: '/async'
|
||||
path: '/async'
|
||||
fullPath: '/async'
|
||||
preLoaderRoute: typeof AsyncRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankRouteImport
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/form': {
|
||||
@@ -119,18 +157,39 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof FormRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/lensform': {
|
||||
id: '/lensform'
|
||||
path: '/lensform'
|
||||
fullPath: '/lensform'
|
||||
preLoaderRoute: typeof LensformRouteImport
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
'/async': {
|
||||
id: '/async'
|
||||
path: '/async'
|
||||
fullPath: '/async'
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -141,8 +200,10 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AsyncRoute: AsyncRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
FormRoute: FormRoute,
|
||||
LensformRoute: LensformRoute,
|
||||
QueryRoute: QueryRoute,
|
||||
ResultRoute: ResultRoute,
|
||||
DevContextRoute: DevContextRoute,
|
||||
DevMemoRoute: DevMemoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
+1
-5
@@ -7,7 +7,7 @@ import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
@@ -17,10 +17,6 @@ function Root() {
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</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>
|
||||
</Container>
|
||||
|
||||
+23
-29
@@ -1,35 +1,30 @@
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-fc-next"
|
||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const fetchPost = (id: number) => Effect.sleep("500 millis").pipe(
|
||||
Effect.as({
|
||||
title: `Post ${id}`,
|
||||
body: `This is the content of post ${id}.`,
|
||||
}),
|
||||
)
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
|
||||
interface AsyncFetchPostViewProps {
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
|
||||
props: AsyncFetchPostViewProps,
|
||||
) {
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted"))
|
||||
yield* Console.log("AsyncFetchPostView mounted")
|
||||
}))
|
||||
|
||||
const post = yield* Component.useOnChange(
|
||||
() => fetchPost(props.id),
|
||||
[props.id],
|
||||
)
|
||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
), [props.id])
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -39,9 +34,10 @@ const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
|
||||
)
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
|
||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
||||
Memoized.memoized,
|
||||
)
|
||||
) {}
|
||||
|
||||
|
||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
||||
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">
|
||||
<TextField.Root
|
||||
value={text}
|
||||
onChange={event => setText(event.currentTarget.value)}
|
||||
onChange={e => setText(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
value={[id]}
|
||||
min={1}
|
||||
max={10}
|
||||
onValueChange={([value]) => setId(value ?? 1)}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
/>
|
||||
|
||||
<AsyncFetchPost id={id} />
|
||||
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context),
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
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 { DateTime, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { QueryClient, ReactRuntime } from "effect-fc-next"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
|
||||
export const layer = Layer.empty.pipe(
|
||||
Layer.provideMerge(QueryClient.layer()),
|
||||
export const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(DateTime.layerCurrentZoneLocal),
|
||||
Layer.provideMerge(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
@@ -13,4 +12,4 @@ export const layer = Layer.empty.pipe(
|
||||
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 react from "@vitejs/plugin-react"
|
||||
import { effectViewPlugin } from "@effect-view/vite-plugin"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
@@ -8,7 +7,6 @@ import { defineConfig } from "vite"
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewPlugin(),
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "effect-fc-next",
|
||||
"name": "effect-view",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.1.0-beta.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
- [@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,
|
||||
},
|
||||
},
|
||||
})
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
- 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,
|
||||
},
|
||||
})
|
||||
```
|
||||
The counter demonstrates a V4 `SubscriptionRef` exposed as an Effect Lens and
|
||||
rendered through an Effect-FC component.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@effect-fc/example",
|
||||
"name": "@effect-view/example",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"dev": "vite",
|
||||
"lint:tsc": "tsc -b --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
@@ -13,6 +14,7 @@
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect-view/vite-plugin": "workspace:*",
|
||||
"@tanstack/react-router": "^1.170.10",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.13",
|
||||
@@ -22,21 +24,18 @@
|
||||
"globals": "^17.6.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"type-fest": "^5.7.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.96.1",
|
||||
"@effect/platform-browser": "^0.76.0",
|
||||
"@effect/platform-browser": "4.0.0-beta.98",
|
||||
"@radix-ui/themes": "^3.3.0",
|
||||
"@typed/id": "^0.17.2",
|
||||
"effect": "^3.21.2",
|
||||
"effect-fc": "workspace:*",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect-fc-next": "workspace:*",
|
||||
"react-icons": "^5.6.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.2.15",
|
||||
"effect": "^3.21.2",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
|
||||
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"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
||||
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||
) {
|
||||
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.isValidating,
|
||||
props.form.isCommitting,
|
||||
@@ -29,7 +29,7 @@ export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(f
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={isCommitting}
|
||||
{...Struct.omit(props, "form")}
|
||||
{...Struct.omit(props, ["form"])}
|
||||
>
|
||||
{isValidating &&
|
||||
<TextField.Slot side="right">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||
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"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
||||
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
|
||||
) {
|
||||
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.isValidating,
|
||||
props.form.isCommitting,
|
||||
@@ -29,7 +29,7 @@ export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalF
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={!input.enabled || isCommitting}
|
||||
{...Struct.omit(props, "form", "defaultValue")}
|
||||
{...Struct.omit(props, ["form", "defaultValue"])}
|
||||
>
|
||||
<TextField.Slot side="left">
|
||||
<Switch
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { ReactRuntime } from "effect-fc-next"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
interface Register { 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(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<RouterProvider router={router} />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 DevMemoRouteImport } from './routes/dev/memo'
|
||||
import { Route as DevContextRouteImport } from './routes/dev/context'
|
||||
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'
|
||||
|
||||
const ResultRoute = ResultRouteImport.update({
|
||||
id: '/result',
|
||||
path: '/result',
|
||||
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',
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AsyncRoute = AsyncRouteImport.update({
|
||||
@@ -43,19 +26,24 @@ const AsyncRoute = AsyncRouteImport.update({
|
||||
path: '/async',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
const BlankRoute = BlankRouteImport.update({
|
||||
id: '/blank',
|
||||
path: '/blank',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevMemoRoute = DevMemoRouteImport.update({
|
||||
id: '/dev/memo',
|
||||
path: '/dev/memo',
|
||||
const FormRoute = FormRouteImport.update({
|
||||
id: '/form',
|
||||
path: '/form',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DevContextRoute = DevContextRouteImport.update({
|
||||
id: '/dev/context',
|
||||
path: '/dev/context',
|
||||
const LensformRoute = LensformRouteImport.update({
|
||||
id: '/lensform',
|
||||
path: '/lensform',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const QueryRoute = QueryRouteImport.update({
|
||||
id: '/query',
|
||||
path: '/query',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
@@ -64,20 +52,16 @@ export interface FileRoutesByFullPath {
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -85,42 +69,15 @@ export interface FileRoutesById {
|
||||
'/async': typeof AsyncRoute
|
||||
'/blank': typeof BlankRoute
|
||||
'/form': typeof FormRoute
|
||||
'/lensform': typeof LensformRoute
|
||||
'/query': typeof QueryRoute
|
||||
'/result': typeof ResultRoute
|
||||
'/dev/context': typeof DevContextRoute
|
||||
'/dev/memo': typeof DevMemoRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
fullPaths: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/async'
|
||||
| '/blank'
|
||||
| '/form'
|
||||
| '/query'
|
||||
| '/result'
|
||||
| '/dev/context'
|
||||
| '/dev/memo'
|
||||
to: '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
id: '__root__' | '/' | '/async' | '/blank' | '/form' | '/lensform' | '/query'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -128,40 +85,17 @@ export interface RootRouteChildren {
|
||||
AsyncRoute: typeof AsyncRoute
|
||||
BlankRoute: typeof BlankRoute
|
||||
FormRoute: typeof FormRoute
|
||||
LensformRoute: typeof LensformRoute
|
||||
QueryRoute: typeof QueryRoute
|
||||
ResultRoute: typeof ResultRoute
|
||||
DevContextRoute: typeof DevContextRoute
|
||||
DevMemoRoute: typeof DevMemoRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/result': {
|
||||
id: '/result'
|
||||
path: '/result'
|
||||
fullPath: '/result'
|
||||
preLoaderRoute: typeof ResultRouteImport
|
||||
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
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/async': {
|
||||
@@ -171,25 +105,32 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AsyncRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
'/blank': {
|
||||
id: '/blank'
|
||||
path: '/blank'
|
||||
fullPath: '/blank'
|
||||
preLoaderRoute: typeof BlankRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/memo': {
|
||||
id: '/dev/memo'
|
||||
path: '/dev/memo'
|
||||
fullPath: '/dev/memo'
|
||||
preLoaderRoute: typeof DevMemoRouteImport
|
||||
'/form': {
|
||||
id: '/form'
|
||||
path: '/form'
|
||||
fullPath: '/form'
|
||||
preLoaderRoute: typeof FormRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dev/context': {
|
||||
id: '/dev/context'
|
||||
path: '/dev/context'
|
||||
fullPath: '/dev/context'
|
||||
preLoaderRoute: typeof DevContextRouteImport
|
||||
'/lensform': {
|
||||
id: '/lensform'
|
||||
path: '/lensform'
|
||||
fullPath: '/lensform'
|
||||
preLoaderRoute: typeof LensformRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/query': {
|
||||
id: '/query'
|
||||
path: '/query'
|
||||
fullPath: '/query'
|
||||
preLoaderRoute: typeof QueryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
@@ -200,10 +141,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AsyncRoute: AsyncRoute,
|
||||
BlankRoute: BlankRoute,
|
||||
FormRoute: FormRoute,
|
||||
LensformRoute: LensformRoute,
|
||||
QueryRoute: QueryRoute,
|
||||
ResultRoute: ResultRoute,
|
||||
DevContextRoute: DevContextRoute,
|
||||
DevMemoRoute: DevMemoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -7,7 +7,7 @@ import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
component: Root,
|
||||
})
|
||||
|
||||
function Root() {
|
||||
@@ -17,6 +17,10 @@ function Root() {
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</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>
|
||||
</Container>
|
||||
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Array, Effect, flow, Option, Schema } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-fc"
|
||||
import { Console, Effect } from "effect"
|
||||
import { Async, Component, Memoized } from "effect-fc-next"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const Post = Schema.Struct({
|
||||
userId: Schema.Int,
|
||||
id: Schema.Int,
|
||||
title: Schema.String,
|
||||
body: Schema.String,
|
||||
})
|
||||
const fetchPost = (id: number) => Effect.sleep("500 millis").pipe(
|
||||
Effect.as({
|
||||
title: `Post ${id}`,
|
||||
body: `This is the content of post ${id}.`,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
interface AsyncFetchPostViewProps {
|
||||
readonly id: number
|
||||
}
|
||||
|
||||
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
|
||||
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
|
||||
Effect.andThen(response => response.json),
|
||||
Effect.andThen(Schema.decodeUnknown(Post)),
|
||||
), [props.id])
|
||||
const AsyncFetchPostView = Component.make("AsyncFetchPostView")(function*(
|
||||
props: AsyncFetchPostViewProps,
|
||||
) {
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("AsyncFetchPostView unmounted"))
|
||||
yield* Console.log("AsyncFetchPostView mounted")
|
||||
}))
|
||||
|
||||
const post = yield* Component.useOnChange(
|
||||
() => fetchPost(props.id),
|
||||
[props.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -34,10 +39,9 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(
|
||||
)
|
||||
}).pipe(
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
|
||||
Async.withOptions({ defaultFallback: <Text>Loading post...</Text> }),
|
||||
Memoized.memoized,
|
||||
) {}
|
||||
|
||||
)
|
||||
|
||||
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
|
||||
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">
|
||||
<TextField.Root
|
||||
value={text}
|
||||
onChange={e => setText(e.currentTarget.value)}
|
||||
onChange={event => setText(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
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>
|
||||
</Container>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/async")({
|
||||
|
||||
@@ -1,143 +1,82 @@
|
||||
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 { Console, Effect, Schema } from "effect"
|
||||
import { Component, Form, MutationForm, View } from "effect-fc-next"
|
||||
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)
|
||||
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}`),
|
||||
})
|
||||
|
||||
return {
|
||||
form,
|
||||
emailField: Form.focusObjectOn(form, "email"),
|
||||
passwordField: Form.focusObjectOn(form, "password"),
|
||||
birthField: Form.focusObjectOn(form, "birth"),
|
||||
} as const
|
||||
})
|
||||
}) {}
|
||||
const emailField = Form.focusObjectOn(form, "email")
|
||||
const passwordField = Form.focusObjectOn(form, "password")
|
||||
|
||||
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
|
||||
const form = yield* RegisterFormService
|
||||
const [canCommit, submitResult] = yield* Subscribable.useAll([
|
||||
form.form.canCommit,
|
||||
form.form.mutation.result,
|
||||
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 TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
|
||||
|
||||
yield* Component.useOnMount(() => Effect.gen(function*() {
|
||||
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
|
||||
yield* Console.log("RegisterFormView mounted")
|
||||
}))
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
|
||||
return (
|
||||
<Container width="300">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void runPromise(form.form.submit)
|
||||
<form onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void runPromise(form.submit)
|
||||
}}>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextFieldFormInput
|
||||
form={form.emailField}
|
||||
form={emailField}
|
||||
placeholder="Email"
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldFormInput
|
||||
form={form.passwordField}
|
||||
form={passwordField}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
debounce="250 millis"
|
||||
/>
|
||||
|
||||
<TextFieldOptionalFormInput
|
||||
type="datetime-local"
|
||||
form={form.birthField}
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canCommit}>Submit</Button>
|
||||
<Button disabled={!canCommit || isCommitting}>
|
||||
{isCommitting ? "Submitting…" : "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,
|
||||
)}
|
||||
<Text size="2">A MutationForm validates local input, then submits it.</Text>
|
||||
</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)
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/form")({
|
||||
component: RegisterPage
|
||||
component: RegisterRouteComponent,
|
||||
})
|
||||
|
||||
@@ -1,24 +1,56 @@
|
||||
import { Button, Container, Flex, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { Effect, SubscriptionRef } from "effect"
|
||||
import { Component, Lens, View } from "effect-fc-next"
|
||||
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* Effect.provide(
|
||||
TodosView.use,
|
||||
yield* Component.useLayer(TodosStateLive),
|
||||
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 <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(
|
||||
Component.withRuntime(runtime.context)
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index
|
||||
component: TodoRouteComponent,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { 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"
|
||||
|
||||
|
||||
@@ -13,30 +14,45 @@ const Post = Schema.Struct({
|
||||
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 = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
|
||||
const key = Stream.map(idLens.changes, id => [id] as const)
|
||||
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,
|
||||
f: ([id]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
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.decodeUnknown(Post)),
|
||||
Effect.andThen(Schema.decodeUnknownEffect(Post)),
|
||||
),
|
||||
staleTime: "10 seconds",
|
||||
})
|
||||
|
||||
const mutation = yield* Mutation.make({
|
||||
f: ([id]: readonly [id: number]) => HttpClient.HttpClient.pipe(
|
||||
Effect.tap(Effect.sleep("500 millis")),
|
||||
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.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 [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(
|
||||
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,
|
||||
))
|
||||
const runSync = yield* Component.useRunSync()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Flex direction="column" align="center" gap="2">
|
||||
<Slider
|
||||
value={[id]}
|
||||
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
|
||||
min={1}
|
||||
max={10}
|
||||
onValueChange={([value]) => setId(value ?? 1)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<PostResultView result={queryState.result} />
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(query.refresh)}>Refresh</Button>
|
||||
<Button onClick={() => runPromise(query.invalidateCache)}>Invalidate cache</Button>
|
||||
<Button onClick={() => runSync(query.refreshView)}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => runSync(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>
|
||||
<PostResultView result={mutationState} />
|
||||
|
||||
<Flex direction="row" justify="center" align="center" gap="1">
|
||||
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
|
||||
</Flex>
|
||||
<Button onClick={() => runSync(mutation.mutateView([id]))}>
|
||||
Mutate
|
||||
</Button>
|
||||
</Flex>
|
||||
</Container>
|
||||
)
|
||||
})
|
||||
}).pipe(
|
||||
Component.withContext(runtime.context),
|
||||
)
|
||||
|
||||
export const Route = createFileRoute("/query")({
|
||||
component: Component.withRuntime(ResultView, runtime.context)
|
||||
component: QueryRouteComponent,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
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(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
@@ -12,4 +13,4 @@ export const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
export const runtime = ReactRuntime.make(layer)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { effectViewPlugin } from "@effect-view/vite-plugin"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
@@ -7,6 +8,7 @@ import { defineConfig } from "vite"
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
effectViewPlugin(),
|
||||
tanstackRouter({
|
||||
target: "react",
|
||||
autoCodeSplitting: true,
|
||||
|
||||
Reference in New Issue
Block a user