diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml
index 87cc2d2..7efb5a0 100644
--- a/.gitea/workflows/lint.yaml
+++ b/.gitea/workflows/lint.yaml
@@ -8,13 +8,9 @@ jobs:
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: "20"
- name: Clone repo
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint TypeScript
- run: npm run lint:tsc
+ run: bun run lint:tsc
diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml
index 1b33cdb..7289f97 100644
--- a/.gitea/workflows/publish.yaml
+++ b/.gitea/workflows/publish.yaml
@@ -15,14 +15,18 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "20"
+ registry-url: "https://registry.npmjs.org"
- name: Clone repo
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
- run: npm run build
+ run: |
+ cd packages/reffuse
+ bun run build
- name: Publish
run: |
- npm config set @thilawyn:registry https://git.valverde.cloud/api/packages/thilawyn/npm/
- npm config set -- //git.valverde.cloud/api/packages/thilawyn/npm/:_authToken "${{ vars.NODE_AUTH_TOKEN }}"
- npm publish
+ cd packages/reffuse
+ npm publish --provenance --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml
index 0f61dc5..ee239a7 100644
--- a/.gitea/workflows/test-build.yaml
+++ b/.gitea/workflows/test-build.yaml
@@ -18,6 +18,10 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
- run: npm run build
+ run: |
+ cd packages/reffuse
+ bun run build
- name: Pack
- run: npm pack --dry-run
+ run: |
+ cd packages/reffuse
+ npm pack --dry-run
diff --git a/README.md b/README.md
index 7d92783..dab847a 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,12 @@
# Reffuse
-Effect integration for React
+[Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals.
+
+This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
+
+Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
+
+## Dependencies
+(needs to be manually installed)
+- `effect`
+- `react` 19+
diff --git a/bun.lockb b/bun.lockb
new file mode 100755
index 0000000..ea85420
Binary files /dev/null and b/bun.lockb differ
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..4fe1e6e
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[install.scopes]
+"@thilawyn" = "https://git.valverde.cloud/api/packages/thilawyn/npm/"
diff --git a/package.json b/package.json
index 8781544..6d4beab 100644
--- a/package.json
+++ b/package.json
@@ -2,8 +2,14 @@
"private": true,
"workspaces": ["./packages/*"],
"scripts": {
+ "lint:tsc": "bun run --filter '*' lint:tsc",
"clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules"
+ },
+ "devDependencies": {
+ "npm-check-updates": "^17.1.13",
+ "npm-sort": "^0.0.4",
+ "typescript": "^5.7.3"
}
}
diff --git a/packages/example/.gitignore b/packages/example/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/packages/example/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/example/README.md b/packages/example/README.md
new file mode 100644
index 0000000..74872fd
--- /dev/null
+++ b/packages/example/README.md
@@ -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,
+ },
+})
+```
diff --git a/packages/example/eslint.config.js b/packages/example/eslint.config.js
new file mode 100644
index 0000000..092408a
--- /dev/null
+++ b/packages/example/eslint.config.js
@@ -0,0 +1,28 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+
+export default tseslint.config(
+ { ignores: ['dist'] },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+ },
+)
diff --git a/packages/example/index.html b/packages/example/index.html
new file mode 100644
index 0000000..e4b78ea
--- /dev/null
+++ b/packages/example/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/packages/example/package.json b/packages/example/package.json
new file mode 100644
index 0000000..d73afec
--- /dev/null
+++ b/packages/example/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@reffuse/example",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint:tsc": "tsc --noEmit",
+ "lint:eslint": "eslint .",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.17.0",
+ "@tanstack/react-router": "^1.95.3",
+ "@tanstack/router-devtools": "^1.95.3",
+ "@tanstack/router-plugin": "^1.95.3",
+ "@thilawyn/thilaschema": "^0.1.4",
+ "@types/react": "^19.0.4",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "effect": "^3.12.1",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-react-refresh": "^0.4.16",
+ "globals": "^15.14.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "reffuse": "workspace:*",
+ "typescript-eslint": "^8.18.2",
+ "vite": "^6.0.5"
+ },
+ "dependencies": {
+ "@effect/platform": "^0.73.1",
+ "@effect/platform-browser": "^0.52.1",
+ "@radix-ui/themes": "^3.1.6",
+ "@typed/id": "^0.17.1",
+ "lucide-react": "^0.471.1",
+ "mobx": "^6.13.5"
+ }
+}
diff --git a/packages/example/public/vite.svg b/packages/example/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/packages/example/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/example/src/domain/Todo.ts b/packages/example/src/domain/Todo.ts
new file mode 100644
index 0000000..cd120b3
--- /dev/null
+++ b/packages/example/src/domain/Todo.ts
@@ -0,0 +1,26 @@
+import { ThSchema } from "@thilawyn/thilaschema"
+import { GetRandomValues, makeUuid4 } from "@typed/id"
+import { Effect, Schema } from "effect"
+
+
+export class Todo extends Schema.Class("Todo")({
+ _tag: Schema.tag("Todo"),
+ id: Schema.String,
+ content: Schema.String,
+ completedAt: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
+}) {}
+
+
+export const TodoFromJsonStruct = Schema.Struct({
+ ...Todo.fields,
+ completedAt: Schema.Option(Schema.DateTimeUtc),
+}).pipe(
+ ThSchema.assertEncodedJsonifiable
+)
+
+export const TodoFromJson = TodoFromJsonStruct.pipe(Schema.compose(Todo))
+
+
+export const generateUniqueID = makeUuid4.pipe(
+ Effect.provide(GetRandomValues.CryptoRandom)
+)
diff --git a/packages/example/src/domain/index.ts b/packages/example/src/domain/index.ts
new file mode 100644
index 0000000..8d70c13
--- /dev/null
+++ b/packages/example/src/domain/index.ts
@@ -0,0 +1 @@
+export * as Todo from "./Todo"
diff --git a/packages/example/src/index.css b/packages/example/src/index.css
new file mode 100644
index 0000000..e69de29
diff --git a/packages/example/src/main.tsx b/packages/example/src/main.tsx
new file mode 100644
index 0000000..f3e3987
--- /dev/null
+++ b/packages/example/src/main.tsx
@@ -0,0 +1,36 @@
+import { FetchHttpClient } from "@effect/platform"
+import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
+import { createRouter, RouterProvider } from "@tanstack/react-router"
+import { ReffuseRuntime } from "@thilawyn/reffuse"
+import { Layer } from "effect"
+import { StrictMode } from "react"
+import { createRoot } from "react-dom/client"
+import { GlobalContext } from "./reffuse"
+import { routeTree } from "./routeTree.gen"
+
+
+const layer = Layer.empty.pipe(
+ Layer.provideMerge(Clipboard.layer),
+ Layer.provideMerge(Geolocation.layer),
+ Layer.provideMerge(Permissions.layer),
+ Layer.provideMerge(FetchHttpClient.layer),
+)
+
+const router = createRouter({ routeTree })
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router
+ }
+}
+
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+)
diff --git a/packages/example/src/reffuse.ts b/packages/example/src/reffuse.ts
new file mode 100644
index 0000000..beba3d3
--- /dev/null
+++ b/packages/example/src/reffuse.ts
@@ -0,0 +1,13 @@
+import { HttpClient } from "@effect/platform"
+import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
+import { Reffuse, ReffuseContext } from "reffuse"
+
+
+export const GlobalContext = ReffuseContext.make<
+ | Clipboard.Clipboard
+ | Geolocation.Geolocation
+ | Permissions.Permissions
+ | HttpClient.HttpClient
+>()
+
+export const R = Reffuse.make(GlobalContext)
diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts
new file mode 100644
index 0000000..2cec328
--- /dev/null
+++ b/packages/example/src/routeTree.gen.ts
@@ -0,0 +1,134 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+// Import Routes
+
+import { Route as rootRoute } from './routes/__root'
+import { Route as TimeImport } from './routes/time'
+import { Route as CountImport } from './routes/count'
+import { Route as IndexImport } from './routes/index'
+
+// Create/Update Routes
+
+const TimeRoute = TimeImport.update({
+ id: '/time',
+ path: '/time',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const CountRoute = CountImport.update({
+ id: '/count',
+ path: '/count',
+ getParentRoute: () => rootRoute,
+} as any)
+
+const IndexRoute = IndexImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => rootRoute,
+} as any)
+
+// Populate the FileRoutesByPath interface
+
+declare module '@tanstack/react-router' {
+ interface FileRoutesByPath {
+ '/': {
+ id: '/'
+ path: '/'
+ fullPath: '/'
+ preLoaderRoute: typeof IndexImport
+ parentRoute: typeof rootRoute
+ }
+ '/count': {
+ id: '/count'
+ path: '/count'
+ fullPath: '/count'
+ preLoaderRoute: typeof CountImport
+ parentRoute: typeof rootRoute
+ }
+ '/time': {
+ id: '/time'
+ path: '/time'
+ fullPath: '/time'
+ preLoaderRoute: typeof TimeImport
+ parentRoute: typeof rootRoute
+ }
+ }
+}
+
+// Create and export the route tree
+
+export interface FileRoutesByFullPath {
+ '/': typeof IndexRoute
+ '/count': typeof CountRoute
+ '/time': typeof TimeRoute
+}
+
+export interface FileRoutesByTo {
+ '/': typeof IndexRoute
+ '/count': typeof CountRoute
+ '/time': typeof TimeRoute
+}
+
+export interface FileRoutesById {
+ __root__: typeof rootRoute
+ '/': typeof IndexRoute
+ '/count': typeof CountRoute
+ '/time': typeof TimeRoute
+}
+
+export interface FileRouteTypes {
+ fileRoutesByFullPath: FileRoutesByFullPath
+ fullPaths: '/' | '/count' | '/time'
+ fileRoutesByTo: FileRoutesByTo
+ to: '/' | '/count' | '/time'
+ id: '__root__' | '/' | '/count' | '/time'
+ fileRoutesById: FileRoutesById
+}
+
+export interface RootRouteChildren {
+ IndexRoute: typeof IndexRoute
+ CountRoute: typeof CountRoute
+ TimeRoute: typeof TimeRoute
+}
+
+const rootRouteChildren: RootRouteChildren = {
+ IndexRoute: IndexRoute,
+ CountRoute: CountRoute,
+ TimeRoute: TimeRoute,
+}
+
+export const routeTree = rootRoute
+ ._addFileChildren(rootRouteChildren)
+ ._addFileTypes()
+
+/* ROUTE_MANIFEST_START
+{
+ "routes": {
+ "__root__": {
+ "filePath": "__root.tsx",
+ "children": [
+ "/",
+ "/count",
+ "/time"
+ ]
+ },
+ "/": {
+ "filePath": "index.tsx"
+ },
+ "/count": {
+ "filePath": "count.tsx"
+ },
+ "/time": {
+ "filePath": "time.tsx"
+ }
+ }
+}
+ROUTE_MANIFEST_END */
diff --git a/packages/example/src/routes/__root.tsx b/packages/example/src/routes/__root.tsx
new file mode 100644
index 0000000..beef033
--- /dev/null
+++ b/packages/example/src/routes/__root.tsx
@@ -0,0 +1,27 @@
+import { Container, Flex, Theme } from "@radix-ui/themes"
+import "@radix-ui/themes/styles.css"
+import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
+import { TanStackRouterDevtools } from "@tanstack/router-devtools"
+import "../index.css"
+
+
+export const Route = createRootRoute({
+ component: Root
+})
+
+function Root() {
+ return (
+
+
+
+ Index
+ Time
+ Count
+
+
+
+
+
+
+ )
+}
diff --git a/packages/example/src/routes/count.tsx b/packages/example/src/routes/count.tsx
new file mode 100644
index 0000000..77aedc7
--- /dev/null
+++ b/packages/example/src/routes/count.tsx
@@ -0,0 +1,27 @@
+import { R } from "@/reffuse"
+import { createFileRoute } from "@tanstack/react-router"
+import { Ref } from "effect"
+
+
+export const Route = createFileRoute("/count")({
+ component: Count
+})
+
+function Count() {
+
+ const runSync = R.useRunSync()
+
+ const countRef = R.useRef(0)
+ const [count] = R.useRefState(countRef)
+
+
+ return (
+
+ {/*
+ )
+
+}
diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx
new file mode 100644
index 0000000..c4caacd
--- /dev/null
+++ b/packages/example/src/routes/index.tsx
@@ -0,0 +1,34 @@
+import { R } from "@/reffuse"
+import { TodosContext } from "@/todos/reffuse"
+import { TodosState } from "@/todos/services"
+import { VTodos } from "@/todos/views/VTodos"
+import { Container } from "@radix-ui/themes"
+import { createFileRoute } from "@tanstack/react-router"
+import { Console, Effect, Layer } from "effect"
+import { useMemo } from "react"
+
+
+export const Route = createFileRoute("/")({
+ component: Index
+})
+
+function Index() {
+
+ const todosLayer = useMemo(() => Layer.empty.pipe(
+ Layer.provideMerge(TodosState.make("todos"))
+ ), [])
+
+ R.useEffect(Effect.addFinalizer(() => Console.log("Effect cleanup")).pipe(
+ Effect.flatMap(() => Console.log("Effect recalculated"))
+ ))
+
+
+ return (
+
+
+
+
+
+ )
+
+}
diff --git a/packages/example/src/routes/time.tsx b/packages/example/src/routes/time.tsx
new file mode 100644
index 0000000..e2ba3d8
--- /dev/null
+++ b/packages/example/src/routes/time.tsx
@@ -0,0 +1,49 @@
+import { R } from "@/reffuse"
+import { createFileRoute } from "@tanstack/react-router"
+import { Console, DateTime, Effect, Ref, Schedule, Stream } from "effect"
+
+
+const timeEverySecond = Stream.repeatEffectWithSchedule(
+ DateTime.now,
+ Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
+)
+
+
+export const Route = createFileRoute("/time")({
+ component: Time
+})
+
+function Time() {
+
+ const timeRef = R.useRefFromEffect(DateTime.now)
+
+ R.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
+ Effect.flatMap(() =>
+ Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v))
+ )
+ ), [timeRef])
+ // Reffuse.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
+ // Effect.flatMap(() => DateTime.now),
+ // Effect.flatMap(v => Ref.set(timeRef, v)),
+ // Effect.repeat(Schedule.intersect(
+ // Schedule.forever,
+ // Schedule.spaced("1 second"),
+ // )),
+ // ), [timeRef])
+
+ const [time] = R.useRefState(timeRef)
+
+
+ return (
+
+
+ {DateTime.format(time, {
+ hour: "numeric",
+ minute: "numeric",
+ second: "numeric",
+ })}
+
+
+ )
+
+}
diff --git a/packages/example/src/services/index.ts b/packages/example/src/services/index.ts
new file mode 100644
index 0000000..336ce12
--- /dev/null
+++ b/packages/example/src/services/index.ts
@@ -0,0 +1 @@
+export {}
diff --git a/packages/example/src/todos/reffuse.ts b/packages/example/src/todos/reffuse.ts
new file mode 100644
index 0000000..8502e12
--- /dev/null
+++ b/packages/example/src/todos/reffuse.ts
@@ -0,0 +1,7 @@
+import { GlobalContext } from "@/reffuse"
+import { Reffuse, ReffuseContext } from "reffuse"
+import { TodosState } from "./services"
+
+
+export const TodosContext = ReffuseContext.make()
+export const R = Reffuse.make(GlobalContext, TodosContext)
diff --git a/packages/example/src/todos/services/TodosState.ts b/packages/example/src/todos/services/TodosState.ts
new file mode 100644
index 0000000..d9b2bea
--- /dev/null
+++ b/packages/example/src/todos/services/TodosState.ts
@@ -0,0 +1,69 @@
+import { Todo } from "@/domain"
+import { KeyValueStore } from "@effect/platform"
+import { BrowserKeyValueStore } from "@effect/platform-browser"
+import { PlatformError } from "@effect/platform/Error"
+import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, SubscriptionRef } from "effect"
+
+
+export class TodosState extends Context.Tag("TodosState")>
+
+ readonly readFromLocalStorage: Effect.Effect
+ readonly saveToLocalStorage: Effect.Effect
+
+ readonly prepend: (todo: Todo.Todo) => Effect.Effect
+ readonly replace: (index: number, todo: Todo.Todo) => Effect.Effect
+ readonly remove: (index: number) => Effect.Effect
+ // readonly moveUp: (index: number) => Effect.Effect
+ // readonly moveDown: (index: number) => Effect.Effect
+}>() {}
+
+
+export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() {
+ const todos = yield* SubscriptionRef.make(Chunk.empty())
+
+ const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe(
+ Effect.flatMap(kv => kv.get(key)),
+ Effect.flatMap(identity),
+ Effect.flatMap(Schema.parseJson().pipe(
+ Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
+ Schema.decode,
+ )),
+ Effect.flatMap(v => Ref.set(todos, v)),
+
+ Effect.catchTag("NoSuchElementException", () => Ref.set(todos, Chunk.empty())),
+
+ Effect.provide(BrowserKeyValueStore.layerLocalStorage),
+ )
+
+ const saveToLocalStorage = Effect.all([KeyValueStore.KeyValueStore, todos]).pipe(
+ Effect.flatMap(([kv, values]) => values.pipe(
+ Schema.parseJson().pipe(
+ Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
+ Schema.encode,
+ ),
+ Effect.flatMap(v => kv.set(key, v)),
+ )),
+
+ Effect.provide(BrowserKeyValueStore.layerLocalStorage),
+ )
+
+ const prepend = (todo: Todo.Todo) => Ref.update(todos, Chunk.prepend(todo))
+ const replace = (index: number, todo: Todo.Todo) => Ref.update(todos, Chunk.replace(index, todo))
+ const remove = (index: number) => Ref.update(todos, Chunk.remove(index))
+
+ // const moveUp = (index: number) => Effect.gen(function*() {
+
+ // })
+
+ yield* readFromLocalStorage
+
+ return {
+ todos,
+ readFromLocalStorage,
+ saveToLocalStorage,
+ prepend,
+ replace,
+ remove,
+ }
+}))
diff --git a/packages/example/src/todos/services/index.ts b/packages/example/src/todos/services/index.ts
new file mode 100644
index 0000000..5d1c39e
--- /dev/null
+++ b/packages/example/src/todos/services/index.ts
@@ -0,0 +1 @@
+export * as TodosState from "./TodosState"
diff --git a/packages/example/src/todos/views/VNewTodo.tsx b/packages/example/src/todos/views/VNewTodo.tsx
new file mode 100644
index 0000000..fee4f16
--- /dev/null
+++ b/packages/example/src/todos/views/VNewTodo.tsx
@@ -0,0 +1,52 @@
+import { Todo } from "@/domain"
+import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes"
+import { Effect, Option } from "effect"
+import { R } from "../reffuse"
+import { TodosState } from "../services"
+
+
+export function VNewTodo() {
+
+ const runSync = R.useRunSync()
+
+ const createEmptyTodo = Todo.generateUniqueID.pipe(
+ Effect.map(id => Todo.Todo.make({
+ id,
+ content: "",
+ completedAt: Option.none(),
+ }, true))
+ )
+
+ const todoRef = R.useRefFromEffect(createEmptyTodo)
+ const [todo, setTodo] = R.useRefState(todoRef)
+
+
+ return (
+
+
+
+
+
+
+ )
+
+}
diff --git a/packages/example/src/todos/views/VTodo.tsx b/packages/example/src/todos/views/VTodo.tsx
new file mode 100644
index 0000000..4a442bc
--- /dev/null
+++ b/packages/example/src/todos/views/VTodo.tsx
@@ -0,0 +1,56 @@
+import { Todo } from "@/domain"
+import { Box, Card, Flex, IconButton, TextArea } from "@radix-ui/themes"
+import { Effect } from "effect"
+import { Delete } from "lucide-react"
+import { useState } from "react"
+import { R } from "../reffuse"
+import { TodosState } from "../services"
+
+
+export interface VTodoProps {
+ readonly index: number
+ readonly todo: Todo.Todo
+}
+
+export function VTodo({ index, todo }: VTodoProps) {
+
+ const runSync = R.useRunSync()
+ const editorMode = useState(false)
+
+
+ return (
+
+
+
+
+
+
+ )
+
+}
diff --git a/packages/example/src/todos/views/VTodos.tsx b/packages/example/src/todos/views/VTodos.tsx
new file mode 100644
index 0000000..c747afa
--- /dev/null
+++ b/packages/example/src/todos/views/VTodos.tsx
@@ -0,0 +1,36 @@
+import { Box, Flex } from "@radix-ui/themes"
+import { Chunk, Effect, Stream } from "effect"
+import { R } from "../reffuse"
+import { TodosState } from "../services"
+import { VNewTodo } from "./VNewTodo"
+import { VTodo } from "./VTodo"
+
+
+export function VTodos() {
+
+ // Sync changes to the todos with the local storage
+ R.useFork(TodosState.TodosState.pipe(
+ Effect.flatMap(state =>
+ Stream.runForEach(state.todos.changes, () => state.saveToLocalStorage)
+ )
+ ))
+
+ const todosRef = R.useMemo(TodosState.TodosState.pipe(Effect.map(state => state.todos)))
+ const [todos] = R.useRefState(todosRef)
+
+
+ return (
+
+
+
+
+
+ {Chunk.map(todos, (todo, index) => (
+
+
+
+ ))}
+
+ )
+
+}
diff --git a/packages/example/src/vite-env.d.ts b/packages/example/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/packages/example/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/example/tsconfig.app.json b/packages/example/tsconfig.app.json
new file mode 100644
index 0000000..0592374
--- /dev/null
+++ b/packages/example/tsconfig.app.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/packages/example/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/packages/example/tsconfig.node.json b/packages/example/tsconfig.node.json
new file mode 100644
index 0000000..db0becc
--- /dev/null
+++ b/packages/example/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/packages/example/vite.config.ts b/packages/example/vite.config.ts
new file mode 100644
index 0000000..6b20c32
--- /dev/null
+++ b/packages/example/vite.config.ts
@@ -0,0 +1,19 @@
+import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
+import react from "@vitejs/plugin-react"
+import path from "node:path"
+import { defineConfig } from "vite"
+
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [
+ TanStackRouterVite(),
+ react(),
+ ],
+
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+})
diff --git a/packages/reffuse/README.md b/packages/reffuse/README.md
new file mode 100644
index 0000000..dab847a
--- /dev/null
+++ b/packages/reffuse/README.md
@@ -0,0 +1,12 @@
+# Reffuse
+
+[Effect-TS](https://effect.website/) integration for React 19+ with the aim of integrating the Effect context system within React's component hierarchy, while avoiding touching React's internals.
+
+This library is in early development. While it is (almost) feature complete and mostly usable, expect bugs and quirks. Things are still being ironed out, so ideas and criticisms are more than welcome.
+
+Documentation is currently being written. In the meantime, you can take a look at the `packages/example` directory.
+
+## Dependencies
+(needs to be manually installed)
+- `effect`
+- `react` 19+
diff --git a/packages/reffuse/package.json b/packages/reffuse/package.json
index 291ffca..f85e0fb 100644
--- a/packages/reffuse/package.json
+++ b/packages/reffuse/package.json
@@ -1,10 +1,13 @@
{
- "name": "@thilawyn/reffuse",
+ "name": "reffuse",
"version": "0.1.0",
"type": "module",
"files": [
+ "./README.md",
"./dist"
],
+ "license": "MIT",
+ "repository": "github:Thiladev/reffuse",
"types": "./dist/index.d.ts",
"exports": {
".": {
@@ -24,5 +27,8 @@
"clean:node": "rm -rf node_modules"
},
"devDependencies": {
+ "@types/react": "^19.0.4",
+ "effect": "^3.12.1",
+ "react": "^19.0.0"
}
}
diff --git a/packages/reffuse/src/Reffuse.ts b/packages/reffuse/src/Reffuse.ts
new file mode 100644
index 0000000..fd42420
--- /dev/null
+++ b/packages/reffuse/src/Reffuse.ts
@@ -0,0 +1,309 @@
+import { Context, Effect, ExecutionStrategy, Exit, Fiber, Option, Ref, Runtime, Scope, Stream, SubscriptionRef } from "effect"
+import React from "react"
+import * as ReffuseContext from "./ReffuseContext.js"
+import * as ReffuseRuntime from "./ReffuseRuntime.js"
+import * as SetStateAction from "./SetStateAction.js"
+
+
+export class Reffuse {
+
+ constructor(
+ readonly contexts: readonly ReffuseContext.ReffuseContext[]
+ ) {}
+
+
+ useContext(): Context.Context {
+ return ReffuseContext.useMergeAll(...this.contexts)
+ }
+
+
+ useRunSync() {
+ const runtime = ReffuseRuntime.useRuntime()
+ const context = this.useContext()
+
+ return React.useCallback((
+ effect: Effect.Effect
+ ): A => effect.pipe(
+ Effect.provide(context),
+ Runtime.runSync(runtime),
+ ), [runtime, context])
+ }
+
+ useRunPromise() {
+ const runtime = ReffuseRuntime.useRuntime()
+ const context = this.useContext()
+
+ return React.useCallback((
+ effect: Effect.Effect,
+ options?: { readonly signal?: AbortSignal },
+ ): Promise => effect.pipe(
+ Effect.provide(context),
+ effect => Runtime.runPromise(runtime)(effect, options),
+ ), [runtime, context])
+ }
+
+ useRunFork() {
+ const runtime = ReffuseRuntime.useRuntime()
+ const context = this.useContext()
+
+ return React.useCallback((
+ effect: Effect.Effect,
+ options?: Runtime.RunForkOptions,
+ ): Fiber.RuntimeFiber => effect.pipe(
+ Effect.provide(context),
+ effect => Runtime.runFork(runtime)(effect, options),
+ ), [runtime, context])
+ }
+
+
+ /**
+ * Reffuse equivalent to `React.useMemo`.
+ *
+ * `useMemo` will only recompute the memoized value by running the given synchronous effect when one of the deps has changed. \
+ * Trying to run an asynchronous effect will throw.
+ *
+ * Changes to the Reffuse runtime or context will recompute the value in addition to the deps.
+ * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
+ */
+ useMemo(
+ effect: Effect.Effect,
+ deps?: React.DependencyList,
+ options?: RenderOptions,
+ ): A {
+ const runSync = this.useRunSync()
+
+ return React.useMemo(() => runSync(effect), [
+ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
+ ...(deps ?? []),
+ ])
+ }
+
+ /**
+ * Reffuse equivalent to `React.useEffect`.
+ *
+ * Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Trying to run an asynchronous effect will throw.
+ *
+ * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
+ * Add finalizers to the Scope to handle cleanup logic.
+ *
+ * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
+ * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
+ *
+ * ### Example
+ * ```
+ * useEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
+ * Effect.flatMap(() => Console.log("Component mounted"))
+ * ))
+ * ```
+ *
+ * Plain React equivalent:
+ * ```
+ * React.useEffect(() => {
+ * console.log("Component mounted")
+ * return () => { console.log("Component unmounted") }
+ * })
+ * ```
+ */
+ useEffect(
+ effect: Effect.Effect,
+ deps?: React.DependencyList,
+ options?: RenderOptions & ScopeOptions,
+ ): void {
+ const runSync = this.useRunSync()
+
+ return React.useEffect(() => {
+ const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
+ Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)),
+ runSync,
+ )
+
+ return () => { runSync(Scope.close(scope, Exit.void)) }
+ }, [
+ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
+ ...(deps ?? []),
+ ])
+ }
+
+ /**
+ * Reffuse equivalent to `React.useLayoutEffect`.
+ *
+ * Executes a synchronous effect wrapped into a Scope when one of the deps has changed. Fires synchronously after all DOM mutations. \
+ * Trying to run an asynchronous effect will throw.
+ *
+ * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
+ * Add finalizers to the Scope to handle cleanup logic.
+ *
+ * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
+ * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
+ *
+ * ### Example
+ * ```
+ * useLayoutEffect(Effect.addFinalizer(() => Console.log("Component unmounted")).pipe(
+ * Effect.flatMap(() => Console.log("Component mounted"))
+ * ))
+ * ```
+ *
+ * Plain React equivalent:
+ * ```
+ * React.useLayoutEffect(() => {
+ * console.log("Component mounted")
+ * return () => { console.log("Component unmounted") }
+ * })
+ * ```
+ */
+ useLayoutEffect(
+ effect: Effect.Effect,
+ deps?: React.DependencyList,
+ options?: RenderOptions & ScopeOptions,
+ ): void {
+ const runSync = this.useRunSync()
+
+ return React.useLayoutEffect(() => {
+ const scope = Scope.make(options?.finalizerExecutionStrategy).pipe(
+ Effect.tap(scope => Effect.provideService(effect, Scope.Scope, scope)),
+ runSync,
+ )
+
+ return () => { runSync(Scope.close(scope, Exit.void)) }
+ }, [
+ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync],
+ ...(deps ?? []),
+ ])
+ }
+
+ useSuspense(
+ effect: Effect.Effect,
+ deps?: React.DependencyList,
+ options?: { readonly signal?: AbortSignal } & RenderOptions,
+ ): A {
+ const runPromise = this.useRunPromise()
+
+ const promise = React.useMemo(() => runPromise(effect, options), [
+ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runPromise],
+ ...(deps ?? []),
+ ])
+ return React.use(promise)
+ }
+
+ /**
+ * An asynchronous and non-blocking alternative to `React.useEffect`.
+ *
+ * Forks an effect wrapped into a Scope in the background when one of the deps has changed.
+ *
+ * The Scope is closed on every cleanup, i.e. when one of the deps has changed and the effect needs to be re-executed. \
+ * Add finalizers to the Scope to handle cleanup logic.
+ *
+ * Changes to the Reffuse runtime or context will re-execute the effect in addition to the deps.
+ * You can disable this behavior by setting `doNotReExecuteOnRuntimeOrContextChange` to `true` in `options`.
+ *
+ * ### Example
+ * ```
+ * const timeRef = useRefFromEffect(DateTime.now)
+ *
+ * useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
+ * Effect.map(() => Stream.repeatEffectWithSchedule(
+ * DateTime.now,
+ * Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
+ * )),
+ *
+ * Effect.flatMap(Stream.runForEach(time => Ref.set(timeRef, time)),
+ * )), [timeRef])
+ *
+ * const [time] = useRefState(timeRef)
+ * ```
+ */
+ useFork(
+ effect: Effect.Effect,
+ deps?: React.DependencyList,
+ options?: Runtime.RunForkOptions & RenderOptions & ScopeOptions,
+ ): void {
+ const runSync = this.useRunSync()
+ const runFork = this.useRunFork()
+
+ return React.useEffect(() => {
+ const scope = runSync(Scope.make(options?.finalizerExecutionStrategy))
+ const fiber = runFork(Effect.provideService(effect, Scope.Scope, scope), options)
+
+ return () => {
+ Fiber.interrupt(fiber).pipe(
+ Effect.flatMap(() => Scope.close(scope, Exit.void)),
+ runFork,
+ )
+ }
+ }, [
+ ...options?.doNotReExecuteOnRuntimeOrContextChange ? [] : [runSync, runFork],
+ ...(deps ?? []),
+ ])
+ }
+
+
+ useRef(value: A): SubscriptionRef.SubscriptionRef {
+ return this.useMemo(
+ SubscriptionRef.make(value),
+ [],
+ { doNotReExecuteOnRuntimeOrContextChange: false }, // Do not recreate the ref when the context changes
+ )
+ }
+
+ useRefFromEffect(effect: Effect.Effect): SubscriptionRef.SubscriptionRef {
+ return this.useMemo(
+ effect.pipe(Effect.flatMap(SubscriptionRef.make)),
+ [],
+ { doNotReExecuteOnRuntimeOrContextChange: false }, // Do not recreate the ref when the context changes
+ )
+ }
+
+ /**
+ * Binds the state of a `SubscriptionRef` to the state of the React component.
+ *
+ * Returns a [value, setter] tuple just like `React.useState` and triggers a re-render everytime the value of the ref changes.
+ *
+ * Note that the rules of React's immutable state still apply: updating a ref with the same value will not trigger a re-render.
+ */
+ useRefState(ref: SubscriptionRef.SubscriptionRef): [A, React.Dispatch>] {
+ const runSync = this.useRunSync()
+
+ const initialState = React.useMemo(() => runSync(ref), [ref])
+ const [reactStateValue, setReactStateValue] = React.useState(initialState)
+
+ this.useFork(Stream.runForEach(ref.changes, v => Effect.sync(() =>
+ setReactStateValue(v)
+ )), [ref])
+
+ const setValue = React.useCallback((setStateAction: React.SetStateAction) =>
+ runSync(Ref.update(ref, previousState =>
+ SetStateAction.value(setStateAction, previousState)
+ )),
+ [ref])
+
+ return [reactStateValue, setValue]
+ }
+
+
+ useStreamState(stream: Stream.Stream): Option.Option {
+ const [reactStateValue, setReactStateValue] = React.useState(Option.none())
+
+ this.useFork(Stream.runForEach(stream, v => Effect.sync(() =>
+ setReactStateValue(Option.some(v))
+ )), [stream])
+
+ return reactStateValue
+ }
+
+}
+
+
+export interface RenderOptions {
+ /** Prevents re-executing the effect when the Effect runtime or context changes. Defaults to `false`. */
+ readonly doNotReExecuteOnRuntimeOrContextChange?: boolean
+}
+
+export interface ScopeOptions {
+ readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy
+}
+
+
+export const make = >(
+ ...contexts: [...{ [K in keyof T]: ReffuseContext.ReffuseContext }]
+): Reffuse =>
+ new Reffuse(contexts)
diff --git a/packages/reffuse/src/ReffuseContext.tsx b/packages/reffuse/src/ReffuseContext.tsx
new file mode 100644
index 0000000..7d4a17c
--- /dev/null
+++ b/packages/reffuse/src/ReffuseContext.tsx
@@ -0,0 +1,72 @@
+import { Array, Context, Effect, Layer, Runtime } from "effect"
+import React from "react"
+import * as ReffuseRuntime from "./ReffuseRuntime.js"
+
+
+export class ReffuseContext {
+
+ readonly Context = React.createContext>(null!)
+ readonly Provider: ReffuseContextReactProvider
+
+ constructor() {
+ this.Provider = (props) => {
+ const runtime = ReffuseRuntime.useRuntime()
+
+ const value = React.useMemo(() => Effect.context().pipe(
+ Effect.provide(props.layer),
+ Runtime.runSync(runtime),
+ ), [props.layer, runtime])
+
+ return (
+
+ )
+ }
+ this.Provider.displayName = "ReffuseContextReactProvider"
+ }
+
+
+ useContext(): Context.Context {
+ return React.useContext(this.Context)
+ }
+
+ useLayer(): Layer.Layer {
+ const context = this.useContext()
+ return React.useMemo(() => Layer.effectContext(Effect.succeed(context)), [context])
+ }
+
+}
+
+export type ReffuseContextReactProvider = React.FC<{
+ readonly layer: Layer.Layer
+ readonly children?: React.ReactNode
+}>
+
+export type R = T extends ReffuseContext ? R : never
+
+
+export function make() {
+ return new ReffuseContext()
+}
+
+export function useMergeAll>(
+ ...contexts: [...{ [K in keyof T]: ReffuseContext }]
+): Context.Context {
+ const values = contexts.map(v => React.use(v.Context))
+ return React.useMemo(() => Context.mergeAll(...values), values)
+}
+
+export function useMergeAllLayers>(
+ ...contexts: [...{ [K in keyof T]: ReffuseContext }]
+): Layer.Layer {
+ const values = Array.map(
+ contexts as Array.NonEmptyArray>,
+ v => React.use(v.Context),
+ )
+
+ return React.useMemo(() => Layer.mergeAll(
+ ...Array.map(values, context => Layer.effectContext(Effect.succeed(context)))
+ ), values)
+}
diff --git a/packages/reffuse/src/ReffuseRuntime.tsx b/packages/reffuse/src/ReffuseRuntime.tsx
new file mode 100644
index 0000000..d693f6b
--- /dev/null
+++ b/packages/reffuse/src/ReffuseRuntime.tsx
@@ -0,0 +1,15 @@
+import { Runtime } from "effect"
+import React from "react"
+
+
+export const Context = React.createContext>(null!)
+
+export const Provider = (props: { readonly children?: React.ReactNode }) => (
+
+)
+Provider.displayName = "ReffuseRuntimeReactProvider"
+
+export const useRuntime = () => React.useContext(Context)
diff --git a/packages/reffuse/src/SetStateAction.ts b/packages/reffuse/src/SetStateAction.ts
new file mode 100644
index 0000000..f456407
--- /dev/null
+++ b/packages/reffuse/src/SetStateAction.ts
@@ -0,0 +1,12 @@
+import { Function } from "effect"
+import type React from "react"
+
+
+export const value: {
+ (prevState: S): (self: React.SetStateAction) => S
+ (self: React.SetStateAction, prevState: S): S
+} = Function.dual(2, (self: React.SetStateAction, prevState: S): S =>
+ typeof self === "function"
+ ? (self as (prevState: S) => S)(prevState)
+ : self
+)
diff --git a/packages/reffuse/src/index.ts b/packages/reffuse/src/index.ts
index e69de29..01c00cb 100644
--- a/packages/reffuse/src/index.ts
+++ b/packages/reffuse/src/index.ts
@@ -0,0 +1,4 @@
+export * as Reffuse from "./Reffuse.js"
+export * as ReffuseContext from "./ReffuseContext.js"
+export * as ReffuseRuntime from "./ReffuseRuntime.js"
+export * as SetStateAction from "./SetStateAction.js"