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
12 changes: 6 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@

### 🩹 Fixes

- Dispose logic instances synchronously ([1f202e9](https://github.com/sigrea/react/commit/1f202e9))
- Defer logic disposal until post-rerender ([49cacc7](https://github.com/sigrea/react/commit/49cacc7))
- Defer useLogic disposal until rerender completes ([#6](https://github.com/sigrea/react/pull/6))
- Dispose molcule instances synchronously ([1f202e9](https://github.com/sigrea/react/commit/1f202e9))
- Defer molcule disposal until post-rerender ([49cacc7](https://github.com/sigrea/react/commit/49cacc7))
- Defer useMolcule disposal until rerender completes ([#6](https://github.com/sigrea/react/pull/6))

### 📖 Documentation

Expand All @@ -39,11 +39,11 @@

### ✅ Tests

- Cover useLogic rerender and strict mode replay ([5aa1c34](https://github.com/sigrea/react/commit/5aa1c34))
- Cover useMolcule rerender and strict mode replay ([5aa1c34](https://github.com/sigrea/react/commit/5aa1c34))

### 🎨 Styles

- Format strict-mode useLogic test ([ec918fb](https://github.com/sigrea/react/commit/ec918fb))
- Format strict-mode useMolcule test ([ec918fb](https://github.com/sigrea/react/commit/ec918fb))

### ❤️ Contributors

Expand All @@ -54,4 +54,4 @@

### Minor Changes

- e7fa75e: Introduce the initial React adapter hooks for Sigrea logic and signals, including playground scaffolding for manual verification.
- e7fa75e: Introduce the initial React adapter hooks for Sigrea molcules and signals, including playground scaffolding for manual verification.
29 changes: 13 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
# @sigrea/react

`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) logic modules and signals for use in React components. It binds scope-aware lifecycles to `useEffect`, synchronizes signal subscriptions with React rendering, and provides hooks for both shallow and deep reactivity.
`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) molecule modules and signals for use in React components. It binds scope-aware lifecycles to `useEffect`, synchronizes signal subscriptions with React rendering, and provides hooks for both shallow and deep reactivity.

- **Signal subscriptions.** `useSignal` subscribes to signals and computed values, triggering re-renders when they change.
- **Computed subscriptions.** `useComputed` subscribes to computed values and memoizes them per component instance.
- **Deep signal subscriptions.** `useDeepSignal` subscribes to deep signal objects and exposes them for direct mutation.
- **Logic lifecycles.** `useLogic` mounts logic factories and binds their lifecycles to React components.
- **Molecule lifecycles.** `useMolcule` mounts molecule factories and binds their lifecycles to React components.

## Table of Contents

- [Install](#install)
- [Quick Start](#quick-start)
- [Consume a Signal](#consume-a-signal)
- [Bridge Framework-Agnostic Logic](#bridge-framework-agnostic-logic)
- [Bridge Framework-Agnostic Molecules](#bridge-framework-agnostic-molecules)
- [Work with Deep Signals](#work-with-deep-signals)
- [API Reference](#api-reference)
- [useSignal](#usesignal)
- [useComputed](#usecomputed)
- [useDeepSignal](#usedeepsignal)
- [useLogic](#uselogic)
- [useMolcule](#usemolcule)
- [Testing](#testing)
- [Development](#development)
- [License](#license)
Expand Down Expand Up @@ -47,13 +47,13 @@ export function CounterLabel() {
}
```

### Bridge Framework-Agnostic Logic
### Bridge Framework-Agnostic Molecules

```tsx
import { defineLogic, signal } from "@sigrea/core";
import { useLogic, useSignal } from "@sigrea/react";
import { molecule, signal } from "@sigrea/core";
import { useMolcule, useSignal } from "@sigrea/react";

const CounterLogic = defineLogic<{ initialCount: number }>()((props) => {
const CounterMolecule = molecule((props: { initialCount: number }) => {
const count = signal(props.initialCount);

const increment = () => {
Expand All @@ -68,7 +68,7 @@ const CounterLogic = defineLogic<{ initialCount: number }>()((props) => {
});

export function Counter(props: { initialCount: number }) {
const counter = useLogic(CounterLogic, props);
const counter = useMolcule(CounterMolcule, props);
const value = useSignal(counter.count);

return (
Expand Down Expand Up @@ -132,27 +132,24 @@ function useDeepSignal<T extends object>(signal: DeepSignal<T>): T

Exposes a deep signal object for direct mutation within the component. Updates to nested properties trigger re-renders, and the subscription is cleaned up when the component unmounts.

### useLogic
### useMolcule

```tsx
function useLogic<TProps, TReturn>(
logic: LogicFunction<TProps, TReturn>,
function useMolcule<TProps, TReturn>(
molecule: MoleculeFactory<TProps, TReturn>,
props?: TProps
): TReturn
```

Mounts a logic factory and returns its public API. The logic's scope is bound to the component lifecycle: `onMount` callbacks run after the component mounts, and `onUnmount` callbacks run before it unmounts.
Mounts a molecule factory and returns its public API. The molecule's scope is bound to the component lifecycle: `onMount` callbacks run after the component mounts, and `onUnmount` callbacks run before it unmounts.

## Testing

```tsx
// tests/Counter.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { cleanupLogics } from "@sigrea/core";
import { Counter } from "../components/Counter";

afterEach(() => cleanupLogics());

it("increments and displays the updated count", () => {
render(<Counter initialCount={10} />);

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@sigrea/react",
"version": "0.2.1",
"description": "React adapter bindings for Sigrea logic modules.",
"description": "React adapter bindings for Sigrea molecule modules.",
"license": "MIT",
"type": "module",
"packageManager": "pnpm@10.0.0",
Expand Down Expand Up @@ -30,7 +30,7 @@
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"files": ["dist"],
"keywords": ["signals", "reactivity", "react", "logic", "typescript"],
"keywords": ["signals", "reactivity", "react", "molecule", "typescript"],
"scripts": {
"dev": "vite --config playground/vite.config.ts",
"build": "unbuild",
Expand All @@ -44,7 +44,7 @@
"format:fix": "biome check --write ."
},
"peerDependencies": {
"@sigrea/core": "^0.3.1",
"@sigrea/core": "^0.4.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { StrictMode, createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { cleanupLogics, defineLogic, onUnmount } from "@sigrea/core";
import { disposeTrackedMolecules, molecule, onUnmount } from "@sigrea/core";

import { useLogic } from "../useLogic";
import { useMolcule } from "../useMolcule";
import { createTestRoot, flushMicrotasks } from "./testUtils";

describe("useLogic in StrictMode", () => {
describe("useMolcule in StrictMode", () => {
let root: ReturnType<typeof createTestRoot>;

beforeEach(() => {
Expand All @@ -15,18 +15,18 @@ describe("useLogic in StrictMode", () => {

afterEach(async () => {
await root.unmount();
cleanupLogics();
disposeTrackedMolecules();
});

it("keeps the logic instance alive across StrictMode effect replays", async () => {
it("keeps the molecule instance alive across StrictMode effect replays", async () => {
const cleanup = vi.fn();
const logic = defineLogic<number>()((value) => {
const counterMolcule = molecule((value: number) => {
onUnmount(() => cleanup(value));
return { value };
});

function TestComponent() {
useLogic(logic, 1);
useMolcule(counterMolcule, 1);
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import { createElement } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
type LogicInstance,
cleanupLogics,
defineLogic,
type MoleculeInstance,
disposeTrackedMolecules,
molecule,
onUnmount,
} from "@sigrea/core";

import { useLogic } from "../useLogic";
import { useMolcule } from "../useMolcule";
import { createTestRoot, flushMicrotasks } from "./testUtils";

describe("useLogic", () => {
describe("useMolcule", () => {
let root: ReturnType<typeof createTestRoot>;

beforeEach(() => {
Expand All @@ -20,20 +20,20 @@ describe("useLogic", () => {

afterEach(async () => {
await root.unmount();
cleanupLogics();
disposeTrackedMolecules();
});

it("does not dispose when re-rendered with identical props", async () => {
const cleanup = vi.fn();
const logic = defineLogic<number>()((value) => {
const counterMolcule = molecule((value: number) => {
onUnmount(() => cleanup(value));
return { value };
});

const observed: Array<LogicInstance<{ value: number }>> = [];
const observed: Array<MoleculeInstance<{ value: number }>> = [];

function TestComponent({ value }: { value: number }) {
const instance = useLogic(logic, value);
const instance = useMolcule(counterMolcule, value);
observed.push(instance);
return null;
}
Expand All @@ -54,17 +54,17 @@ describe("useLogic", () => {
expect(cleanup).toHaveBeenCalledWith(1);
});

it("mounts logic and cleans up on unmount", async () => {
it("mounts molecule and cleans up on unmount", async () => {
const cleanup = vi.fn();
const makeLogic = defineLogic<number>()((value) => {
const makeMolcule = molecule((value: number) => {
onUnmount(() => cleanup(value));
return { value };
});

const observed: Array<LogicInstance<{ value: number }>> = [];
const observed: Array<MoleculeInstance<{ value: number }>> = [];

function TestComponent() {
const instance = useLogic(makeLogic, 1);
const instance = useMolcule(makeMolcule, 1);
observed.push(instance);
return null;
}
Expand All @@ -86,14 +86,14 @@ describe("useLogic", () => {
const mounts = vi.fn();
const cleanups = vi.fn();

const logic = defineLogic<number>()((value) => {
const counterMolcule = molecule((value: number) => {
mounts(value);
onUnmount(() => cleanups(value));
return {};
});

function TestComponent({ value }: { value: number }) {
useLogic(logic, value);
useMolcule(counterMolcule, value);
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* ==================================================
* logic
* molecule
* ==================================================
*/

export { useLogic } from "./useLogic";
export { useMolcule } from "./useMolcule";

/**
* ==================================================
Expand Down
50 changes: 29 additions & 21 deletions packages/useLogic.ts → packages/useMolcule.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,51 @@
import { useEffect, useRef } from "react";

import type { LogicArgs, LogicFunction, LogicInstance } from "@sigrea/core";
import { cleanupLogic, mountLogic } from "@sigrea/core";

interface LogicState<TReturn extends object, TProps> {
instance: LogicInstance<TReturn>;
logic: LogicFunction<TReturn, TProps>;
import type {
MoleculeArgs,
MoleculeFactory,
MoleculeInstance,
} from "@sigrea/core";
import { disposeMolecule } from "@sigrea/core";

interface MoleculeState<TReturn extends object, TProps> {
instance: MoleculeInstance<TReturn>;
molecule: MoleculeFactory<TReturn, TProps>;
props: TProps | undefined;
subscribers: number;
disposed: boolean;
pendingDisposeToken: symbol | null;
}

export function useLogic<TReturn extends object, TProps = void>(
logic: LogicFunction<TReturn, TProps>,
...args: LogicArgs<TProps>
): LogicInstance<TReturn> {
export function useMolcule<TReturn extends object, TProps = void>(
molecule: MoleculeFactory<TReturn, TProps>,
...args: MoleculeArgs<TProps>
): MoleculeInstance<TReturn> {
const props = args.length === 0 ? undefined : (args[0] as TProps | undefined);
const stateRef = useRef<LogicState<TReturn, TProps> | undefined>(undefined);
const stateRef = useRef<MoleculeState<TReturn, TProps> | undefined>(
undefined,
);

const currentState = stateRef.current;
const shouldRemount =
currentState === undefined ||
currentState.logic !== logic ||
currentState.molecule !== molecule ||
!Object.is(currentState.props, props);

if (shouldRemount) {
if (currentState !== undefined) {
currentState.pendingDisposeToken = null;
cleanupLogic(currentState.instance);
disposeMolecule(currentState.instance);
stateRef.current = undefined;
}

const logicArgs =
const moleculeArgs =
props === undefined
? ([] as LogicArgs<TProps>)
: ([props] as unknown as LogicArgs<TProps>);
? ([] as MoleculeArgs<TProps>)
: ([props] as unknown as MoleculeArgs<TProps>);

stateRef.current = {
instance: mountLogic(logic, ...logicArgs),
logic,
instance: molecule(...moleculeArgs),
molecule,
props,
subscribers: 0,
disposed: false,
Expand All @@ -49,7 +55,9 @@ export function useLogic<TReturn extends object, TProps = void>(

const state = stateRef.current;
if (state === undefined) {
throw new Error("useLogic failed to mount the requested logic instance.");
throw new Error(
"useMolcule failed to mount the requested molecule instance.",
);
}

const instance = state.instance;
Expand All @@ -69,7 +77,7 @@ export function useLogic<TReturn extends object, TProps = void>(
return () => {
const latest = stateRef.current;
if (latest === undefined || latest.instance !== instance) {
cleanupLogic(instance);
disposeMolecule(instance);
return;
}

Expand Down Expand Up @@ -97,7 +105,7 @@ export function useLogic<TReturn extends object, TProps = void>(
current.disposed = true;
current.pendingDisposeToken = null;
stateRef.current = undefined;
cleanupLogic(instance);
disposeMolecule(instance);
});
}
};
Expand Down
Loading