Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
format:
name: Check formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile

- run: bun run format:check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules/
package-lock.json
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 15 additions & 27 deletions docs/adr-001-react-effect.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ Wrap `SubscriptionRef` updates with a Node `EventEmitter`. React subscribes via

The mapping is natural:

| `useSyncExternalStore` contract | Effect primitive |
|---|---|
| `subscribe(callback)` | Fork a fiber consuming `ref.changes`, call `callback` on each emission |
| `getSnapshot()` | `Runtime.runSync(SubscriptionRef.get(ref))` |
| unsubscribe (returned by subscribe) | `Fiber.interrupt(fiber)` |
| `useSyncExternalStore` contract | Effect primitive |
| ----------------------------------- | ---------------------------------------------------------------------- |
| `subscribe(callback)` | Fork a fiber consuming `ref.changes`, call `callback` on each emission |
| `getSnapshot()` | `Runtime.runSync(SubscriptionRef.get(ref))` |
| unsubscribe (returned by subscribe) | `Fiber.interrupt(fiber)` |

## Implementation Sketch

Expand All @@ -89,9 +89,7 @@ export function EffectProvider<R>({
children: ReactNode;
}) {
return (
<RuntimeContext.Provider value={runtime as AnyRuntime}>
{children}
</RuntimeContext.Provider>
<RuntimeContext.Provider value={runtime as AnyRuntime}>{children}</RuntimeContext.Provider>
);
}

Expand All @@ -108,17 +106,13 @@ export function useRuntime<R>(): ManagedRuntime.ManagedRuntime<R, never> {
import { Effect, Fiber, Stream, SubscriptionRef } from "effect";
import { useSyncExternalStore } from "react";

export function useSubscriptionRef<A>(
ref: SubscriptionRef.SubscriptionRef<A>,
): A {
export function useSubscriptionRef<A>(ref: SubscriptionRef.SubscriptionRef<A>): A {
const runtime = useRuntime();

return useSyncExternalStore(
(onStoreChange) => {
const fiber = runtime.runFork(
ref.changes.pipe(
Stream.runForEach(() => Effect.sync(onStoreChange)),
),
ref.changes.pipe(Stream.runForEach(() => Effect.sync(onStoreChange))),
);
return () => {
runtime.runFork(Fiber.interrupt(fiber));
Expand All @@ -135,24 +129,18 @@ export function useSubscriptionRef<A>(
// main.ts
const ShuffleLayer = Layer.succeed(Shuffle, { shuffle: fisherYatesShuffle });
const runtime = ManagedRuntime.make(ShuffleLayer);
const gameRef = runtime.runSync(
newGame().pipe(Effect.andThen(SubscriptionRef.make)),
);
const gameRef = runtime.runSync(newGame().pipe(Effect.andThen(SubscriptionRef.make)));

render(
<EffectProvider runtime={runtime}>
<App gameRef={gameRef} />
</EffectProvider>
</EffectProvider>,
);
```

```tsx
// ui.tsx
export function App({
gameRef,
}: {
gameRef: SubscriptionRef.SubscriptionRef<Game>;
}) {
export function App({ gameRef }: { gameRef: SubscriptionRef.SubscriptionRef<Game> }) {
const game = useSubscriptionRef(gameRef); // rerenders automatically
const runtime = useRuntime();
const [error, setError] = useState<string | null>(null);
Expand All @@ -175,10 +163,10 @@ export function App({

## State Ownership Boundary

| State | Owner | Rationale |
|---|---|---|
| Game (domain) | `SubscriptionRef` in Effect | Single source of truth for engine logic |
| Phase, error message (UI) | React `useState` | Local to the view, not an engine concern |
| State | Owner | Rationale |
| ------------------------- | --------------------------- | ---------------------------------------- |
| Game (domain) | `SubscriptionRef` in Effect | Single source of truth for engine logic |
| Phase, error message (UI) | React `useState` | Local to the view, not an engine concern |

This mirrors the Redux convention: shared/domain state in the store, local UI state
in components.
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"scripts": {
"start": "bun run src/main.ts"
"start": "bun run src/main.ts",
"format:check": "prettier --check ."
},
"dependencies": {
"effect": "3.19.19",
Expand All @@ -9,6 +10,7 @@
},
"devDependencies": {
"@types/bun": "1.3.9",
"@types/react": "19.2.14"
"@types/react": "19.2.14",
"prettier": "3.5.3"
}
}