Skip to content
Closed
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
108 changes: 108 additions & 0 deletions components/state-management/state-management-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# State Management Core

This package provides a simple interface for working with multiple reactive frameworks.

It assumes a view/view model architecture for your app. The view is responsible for what
the user sees, and the view model is responsible for the stateful logic that makes the view
operational.

This package establishes a convention for writing a view model in vanilla JavaScript and
your view in React, Svelte, or some other reactive framework.

This package exists for view models that are exposed as libraries. Since your view model
is not tied to a specific framework, developers can write their own views using their
framework of choice.

## Fields

A field represents a single piece of reactive data. Its usage is best explained with an example.

Suppose your app has a search bar. Every time the user changes the search text, the app
should perform a search.

Here's how you would define the search text field:

```js
const searchText = new Field("", (newSearchText) => {
// This callback runs whenever the user changes the search text.
searchFor(newSearchText);
});

// Get current value of the field
let text = searchText.value;

// Update the field and perform the search.
// This is how the UI should update the field.
searchText.requestUpdate("hello world");

// Update the field without performing the search.
// Your program can use this method internally to update the field without side effects.
searchText.value = "abc";
```

## View Models

In the context of this package, a view model is an object with fields.
To define a view model, write a function like the following:

```js
function usePersonViewModel() {
// The view model can have fields...
const name = new Field();
const age = new Field();

// ...and actions
function haveBirthday() {
age.value++;
}

return { name, age, haveBirthday };
}
```

## Usage in UI framework

Framework-specific adapters make view models accessible to your UI. For example,
if you framework is Svelte, use the `state-management-svelte` package:

```svelte
<script>
import { svelteViewModel } from "@ethnolib/state-management-svelte";

// svelteViewModel() turns all fields into reactive Svelte properties
const person = svelteViewModel(usePersonViewModel())

person.name = "John" // Behind the scenes, this calls `requestUpdate`
</script>

<p>Hello, {person.name}!</p>
```

For more details, see documentation for `state-management-svelte` or the adapter for your
framework of choice.

Behind the scenes, the adapter does something like this to keep the view model in sync with the UI:

```js
const person = usePersonViewModel();

// Replace `defineReactiveState` with your framework's mechanism
const reactiveName = defineReactiveState();

/**
* Establish a two-way binding between `reactiveName` and `person.name`:
*/

// Update the UI in response to the field
person.name.updateUI = (value) => {
reactiveName = value; // Or however you update reactiveName in your framework
};

// Update the field in response to the UI.
// Replace `watch()` with whatever your framework uses to subscribe to a variable.
watch(reactiveName, (value) => person.name.requestUpdate(value));

/**
* Now your UI can use `reactiveName` to interact with the name field.
*/
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./src/use-field";
32 changes: 32 additions & 0 deletions components/state-management/state-management-react/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@ethnolib/state-management-react",
"description": "An adapter to use @ethnolib/state-management-core with React",
"author": "SIL Global",
"license": "MIT",
"version": "0.1.0",
"main": "./index.js",
"types": "./index.d.ts",
"scripts": {
"build": "nx vite:build",
"typecheck": "tsc",
"test": "nx vite:test --config vitest.config.ts",
"testonce": "nx vite:test --config vitest.config.ts --run",
"lint": "eslint ."
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
Comment on lines +16 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Missing @ethnolib/state-management-core dependency in package.json

The React adapter package imports Field from @ethnolib/state-management-core (use-field.ts:2) but does not declare it as a dependency in package.json. The Svelte counterpart (state-management-svelte/package.json) correctly lists "@ethnolib/state-management-core": "0.1.0" in its dependencies. Without this declaration, consumers installing @ethnolib/state-management-react won't get state-management-core resolved, and Vite's library build won't externalize it (bundling a private copy instead).

Suggested change
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0"
},
"dependencies": {
"@ethnolib/state-management-core": "0.1.0"
},
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"devDependencies": {
"@nx/vite": "^19.1.2",
"@types/react": "^17",
"@types/react-dom": "^17",
"@types/node": "^20.16.11",
"@vitejs/plugin-react-swc": "^3.8.0",
"tsx": "^4.19.2",
"typescript": "^5.2.2"
},
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing peerDependencies for "react" (and optionally "react-dom"). Since this package is a React adapter that imports from "react", it should declare React as a peer dependency to ensure consumers have it installed. Consider adding:

"peerDependencies": {
  "react": "^17.0.0 || ^18.0.0"
}
Suggested change
},
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0"
},

Copilot uses AI. Check for mistakes.
"volta": {
"extends": "../../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Field } from "@ethnolib/state-management-core";
import { useField } from "./use-field";

const reactMocks = vi.hoisted(() => ({
setState: vi.fn(),
useState: vi.fn(),
useEffect: vi.fn(),
cleanup: null as null | (() => void),
}));

vi.mock("react", () => ({
useState: reactMocks.useState,
useEffect: reactMocks.useEffect,
}));

describe("useField", () => {
beforeEach(() => {
reactMocks.setState.mockReset();
reactMocks.useState.mockReset();
reactMocks.useEffect.mockReset();
reactMocks.cleanup = null;
reactMocks.useState.mockImplementation((initialValue: unknown) => [
initialValue,
reactMocks.setState,
]);
reactMocks.useEffect.mockImplementation((effect: () => void | (() => void)) => {
const cleanup = effect();
reactMocks.cleanup = typeof cleanup === "function" ? cleanup : null;
});
});

it("returns the current field value and wires updateUI to state updates", () => {
const field = new Field("initial");

const [value] = useField(field);

expect(value).toBe("initial");
expect(reactMocks.useState).toHaveBeenCalledWith("initial");

field.updateUI?.("from-ui");
expect(reactMocks.setState).toHaveBeenCalledWith("from-ui");
});

it("setter requests field update and relies on updateUI for state sync", () => {
const onUpdateRequested = vi.fn();
const field = new Field("old", onUpdateRequested);

const [, setValue] = useField(field);
setValue("new");

expect(field.value).toBe("new");
expect(onUpdateRequested).toHaveBeenCalledWith("new", "old");
expect(reactMocks.setState).toHaveBeenCalledWith("new");
expect(reactMocks.setState).toHaveBeenCalledTimes(1);
});

it("clears field.updateUI on cleanup", () => {
const field = new Field("initial");
useField(field);

expect(field.updateUI).not.toBeNull();
reactMocks.cleanup?.();
expect(field.updateUI).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import { Field } from "@ethnolib/state-management-core";

export function useField<T>(field: Field<T>): [T, (value: T) => void] {
const [fieldValue, _setFieldValue] = useState<T>(field.value as T);

function setFieldValue(value: T) {
field.requestUpdate(value);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

useEffect(() => {
field.updateUI = (value) => _setFieldValue(value as T);
return () => {
field.updateUI = null;
};
}, [field]);
Comment on lines +11 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale React state when field reference changes

When the field argument changes to a different Field instance, useState does not re-initialize — React ignores the useState argument on subsequent renders. The useEffect at line 11 correctly re-wires updateUI on the new field, but never synchronizes React state with the new field's current value. This means the component will render the previous field's last value until something externally triggers updateUI on the new field. The fix is to call _setFieldValue(field.value as T) inside the effect when the field changes.

Suggested change
useEffect(() => {
field.updateUI = (value) => _setFieldValue(value as T);
return () => {
field.updateUI = null;
};
}, [field]);
useEffect(() => {
field.updateUI = (value) => _setFieldValue(value as T);
_setFieldValue(field.value as T);
return () => {
field.updateUI = null;
};
}, [field]);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return [fieldValue, setFieldValue];
}
Comment on lines +4 to +19
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useField hook lacks test coverage. The sibling package state-management-svelte has tests (e.g., transform-view-model.test.ts) for similar functionality. Consider adding tests to verify:

  • Initial state matches field value
  • Updates through setFieldValue call field.requestUpdate
  • Updates through field.updateUI update React state
  • Cleanup of field.updateUI on unmount

Copilot uses AI. Check for mistakes.
22 changes: 22 additions & 0 deletions components/state-management/state-management-react/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true
},
"ts-node": {
"moduleTypes": {
"*": "esm"
}
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
],
"extends": "../../../tsconfig.base.json"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": ["node", "vite/client"],
"composite": true,
"declaration": true,
"declarationMap": true
},
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": [
"./**/*.js",
"./**/*.jsx",
"./**/*.ts",
"./**/*.tsx",
"../index.ts"
]
}
36 changes: 36 additions & 0 deletions components/state-management/state-management-react/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// <reference types='vitest' />
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import * as path from "path";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";

export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/components/state-management/state-management-react",

plugins: [
nxViteTsPaths(),
dts({
entryRoot: ".",
tsconfigPath: path.join(__dirname, "tsconfig.lib.json"),
}),
],

// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
entry: "./index.ts",
name: "@ethnolib/state-management-react",
fileName: "index",
formats: ["es", "cjs"],
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";

export default defineConfig({
test: {
expect: {
requireAssertions: true,
},
},
});
Loading