-
-
Notifications
You must be signed in to change notification settings - Fork 7
Add react implementation of state management #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; |
| 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" | ||||||||||||
| }, | ||||||||||||
| "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" | ||||||||||||
| }, | ||||||||||||
|
||||||||||||
| }, | |
| }, | |
| "peerDependencies": { | |
| "react": "^17.0.0 || ^18.0.0" | |
| }, |
| 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); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Stale React state when When the
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return [fieldValue, setFieldValue]; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+4
to
+19
|
||||||||||||||||||||||||||||
| 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" | ||
| ] | ||
| } |
| 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, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Missing
@ethnolib/state-management-coredependency in package.jsonThe React adapter package imports
Fieldfrom@ethnolib/state-management-core(use-field.ts:2) but does not declare it as a dependency inpackage.json. The Svelte counterpart (state-management-svelte/package.json) correctly lists"@ethnolib/state-management-core": "0.1.0"in itsdependencies. Without this declaration, consumers installing@ethnolib/state-management-reactwon't getstate-management-coreresolved, and Vite's library build won't externalize it (bundling a private copy instead).Was this helpful? React with 👍 or 👎 to provide feedback.