diff --git a/README.md b/README.md index cdbd118..c8f3036 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,211 @@ # React Arven -A lightweight React context helper with **stable action references** and **selective re-renders**. +[![npm](https://img.shields.io/npm/v/react-arven)](https://www.npmjs.com/package/react-arven) +[![bundle size](https://img.shields.io/npm/unpacked-size/react-arven)](https://www.npmjs.com/package/react-arven) +[![license](https://img.shields.io/npm/l/react-arven)](https://github.com/stepan662/react-arven/blob/main/LICENSE) + +A lightweight, fully typed React context helper with **stable action references** and **subscribable context**. + +## Why React Arven? + +Plain `useContext` re-renders every consumer on every state change, which pushes you toward splitting contexts, memoizing selectors, and wrapping every action in `useCallback`. Libraries like Zustand or Jotai solve this but live outside the React component tree — you lose the ability to use hooks naturally inside the store. + +React Arven sits in the middle: it gives you a hook-friendly provider body (just write hooks as you normally would), granular re-renders via selector subscriptions, and stable action references — without any extra boilerplate. + +| | React Arven | plain `useContext` | Zustand | constate | use-context-selector | +|---|---|---|---|---|---| +| Hooks inside store | Yes | Yes | No | Yes | Yes | +| Granular re-renders | Yes | No | Yes | Partial* | Yes | +| Stable action refs | Yes | Manual (`useCallback`) | Yes | Manual (`useCallback`) | Manual (`useCallback`) | +| Scoped to component tree | Yes | Yes | No | Yes | Yes | + +\* constate achieves granular re-renders by splitting your hook into multiple separate contexts — one per value. This works well but requires you to restructure your code around it. React Arven uses selectors on a single context instead. + +## Installation + +Install react-arven with npm, yarn or pnpm: + +```sh +npm i react-arven +``` + +## Usage + +### Create provider + +You create a provider component, you can use hooks there as in a regular React component. Instead of rendering HTML, we return an object with actions and state properties. + +```tsx +import { createProvider } from "react-arven"; + +const [CounterProvider, useCounterActions, useCounterState] = createProvider(() => { + const [count, setCount] = useState(0); + + function increment() { // No useCallback necessary! + setCount(val => val + 1) + } + + return { + actions: { increment }, + state: { count }, + }; +}); +``` + +1. `actions` - is an object with functions, to modify the state, accessible through `useCounterActions` +2. `state` - is a subscribable state, which you can access through `useCounterState` + +You can name your provider and hooks whatever you like. I recommend convention similar to this one. + +Both hooks are typed automatically through type inference, if you use TypeScript. + +### Use provider in your app + +Use the provider component, to provide context to children. + +```tsx +function CounterApp() { + return ( + + + + ) +} +``` + +### Use state and actions + +Now you can use hooks returned from the `createProvider` to use state and actions. + +```tsx +function Counter() { + const count = useCounterState(s => s.count) // selecting only what is needed + const { increment } = useCounterActions() + return ( +
+
Count: {count}
+ +
+ ) +} +``` + +### TypeScript + +The hooks returned by `createProvider` are fully typed — no manual annotations needed. Types are inferred directly from what you return in the provider body: + +```tsx +const [CounterProvider, useCounterActions, useCounterState] = createProvider(() => { + const [count, setCount] = useState(0); + function increment() { setCount(val => val + 1) } + + return { + actions: { increment }, + state: { count }, + }; +}); + +// useCounterState: (selector: (state: { count: number }) => T) => T +// useCounterActions: () => { increment: () => void } +``` + +If you use `createProvider` with props, the provider component is typed accordingly: + +```tsx +type Props = { itemId: number } + +const [ItemDataProvider] = createProvider(({ itemId }: Props) => { ... }); + +// ItemDataProvider expects: { itemId: number, children: React.ReactNode } +``` + +### Actions object + +This library lets you avoid the `useCallback` hook in the provider. Internally, it creates a stable object that mirrors the structure of your `actions`, where each function delegates to your actual implementation. This means that even if the actions object is recreated on every render, components using it won't re-render unnecessarily. + +> **Note:** The actions object must have the same structure on every render and is intended for functions only — do not put data in it. + +### State object + +State object is passed to children "as-is", however you are expected to only select what you need through the selector function. The library will only re-render when the returned value differs (based on `Object.is`). + +So this way you can have very big state, but still be performant and avoid unnecessary re-renders. + +### Early return + +If you know your state is not complete while you are waiting for some async data, you can return a fallback component instead of state and actions. + +```tsx +import { createProvider } from "react-arven"; + +const [CounterProvider, useCounterActions, useCounterState] = createProvider(() => { + const { data, refetch } = useSomeFetchFunction(....); + + if (!data) { + return + } + + return { + actions: { refetch }, + state: { data }, + }; +}); +``` + +If you return a React component from the `createProvider` function, the library will just render the component without providing context and rendering children. + +This way you can make sure your children will never receive `data` as undefined. + +> **Note:** Don't use hooks after early return statement — [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) still apply! + +### Provider with props + +You can pass props to your provider the same way as to any other React component and then use them in the provider body, you can also pass them through context. + +```tsx +type Props = { + itemId: number +} + +const [ItemDataProvider, ...] = createProvider(({ itemId }: Props) => { + const { data, refetch } = useSomeFetchFunction(`/api/item/${itemId}`); + + return { + actions: { refetch }, + state: { data, itemId }, + }; +}); + +// Usage: + +function MyApp() { + return ( + + + + ) +} +``` + +### Using `shallow` + +If you need to transform data in the state selector, use the shallow function to perform comparison on object properties instead of the top-level object. + +```tsx +import { shallow } from 'react-arven' + +function Counter() { + const { count, label } = useCounterState( + s => ({ + count: s.count, + label: c.label + }), + shallow + ) + + ... +} +``` + +This way you'll make sure your component doesn't re-render every time. This functionality is inspired by the Zustand library. diff --git a/lib/package.json b/lib/package.json index 9643bf5..0982abc 100644 --- a/lib/package.json +++ b/lib/package.json @@ -22,8 +22,7 @@ } }, "files": [ - "dist", - "src" + "dist" ], "scripts": { "build": "rollup -c ../rollup.config.js", diff --git a/lib/src/__tests__/createProvider.test.tsx b/lib/tests/createProvider.test.tsx similarity index 99% rename from lib/src/__tests__/createProvider.test.tsx rename to lib/tests/createProvider.test.tsx index bd70ba2..82db914 100644 --- a/lib/src/__tests__/createProvider.test.tsx +++ b/lib/tests/createProvider.test.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { describe, test, expect, vi } from "vitest"; import { act, render, screen } from "@test-utils"; -import { createProvider } from "../createProvider"; +import { createProvider } from "../src/createProvider"; describe("createProvider", () => { test("renders provider and exposes state/actions", () => { diff --git a/lib/src/__tests__/createStableActions.test.ts b/lib/tests/createStableActions.test.ts similarity index 96% rename from lib/src/__tests__/createStableActions.test.ts rename to lib/tests/createStableActions.test.ts index 98cc3fc..5164560 100644 --- a/lib/src/__tests__/createStableActions.test.ts +++ b/lib/tests/createStableActions.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi } from "vitest"; -import { createStableActions, ActionMap } from "../useStableActions"; +import { createStableActions, ActionMap } from "../src/useStableActions"; type CounterActions = { increment: () => number; diff --git a/lib/src/__tests__/renderPerformance.test.tsx b/lib/tests/renderPerformance.test.tsx similarity index 98% rename from lib/src/__tests__/renderPerformance.test.tsx rename to lib/tests/renderPerformance.test.tsx index e54dc87..2a44605 100644 --- a/lib/src/__tests__/renderPerformance.test.tsx +++ b/lib/tests/renderPerformance.test.tsx @@ -1,8 +1,8 @@ import React from "react"; import { describe, test, expect } from "vitest"; import { act, render, screen } from "@test-utils"; -import { createProvider } from "../createProvider"; -import { shallow } from "../shallow"; +import { createProvider } from "../src/createProvider"; +import { shallow } from "../src/shallow"; describe("render performance", () => { test("no unnecessary re-renders on unrelated state change", () => { diff --git a/lib/src/__tests__/shallow.test.ts b/lib/tests/shallow.test.ts similarity index 97% rename from lib/src/__tests__/shallow.test.ts rename to lib/tests/shallow.test.ts index 31da594..5b990b0 100644 --- a/lib/src/__tests__/shallow.test.ts +++ b/lib/tests/shallow.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { shallow } from "../shallow"; +import { shallow } from "../src/shallow"; describe("shallow", () => { test("returns true for the same reference", () => { diff --git a/lib/src/__tests__/test-utils-react16.ts b/lib/tests/test-utils-react16.ts similarity index 100% rename from lib/src/__tests__/test-utils-react16.ts rename to lib/tests/test-utils-react16.ts diff --git a/lib/src/__tests__/test-utils-react17.ts b/lib/tests/test-utils-react17.ts similarity index 100% rename from lib/src/__tests__/test-utils-react17.ts rename to lib/tests/test-utils-react17.ts diff --git a/lib/src/__tests__/test-utils.ts b/lib/tests/test-utils.ts similarity index 100% rename from lib/src/__tests__/test-utils.ts rename to lib/tests/test-utils.ts diff --git a/lib/tsconfig.json b/lib/tsconfig.json index 8dc180c..073fbd2 100644 --- a/lib/tsconfig.json +++ b/lib/tsconfig.json @@ -8,8 +8,8 @@ "noEmit": false, "jsx": "react-jsx", "paths": { - "@test-utils": ["./src/__tests__/test-utils.ts"] + "@test-utils": ["./tests/test-utils.ts"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/createProvider.test.tsx", "tests/createStableActions.test.ts", "tests/renderPerformance.test.tsx", "tests/shallow.test.ts", "tests/test-utils-react16.ts", "tests/test-utils-react17.ts", "tests/test-utils.ts"] } diff --git a/lib/vitest.config.ts b/lib/vitest.config.ts index 1b968b7..9a719aa 100644 --- a/lib/vitest.config.ts +++ b/lib/vitest.config.ts @@ -1,19 +1,21 @@ -import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; -import { fileURLToPath } from 'url'; +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "url"; export default defineConfig({ plugins: [react()], test: { - environment: 'jsdom', + environment: "jsdom", globals: true, }, resolve: { alias: [ { - find: '@test-utils', - replacement: fileURLToPath(new URL('./src/__tests__/test-utils.ts', import.meta.url)), + find: "@test-utils", + replacement: fileURLToPath( + new URL("./tests/test-utils.ts", import.meta.url), + ), }, ], }, -}); \ No newline at end of file +}); diff --git a/lib/vitest.workspace.ts b/lib/vitest.workspace.ts index 27cc684..7725ee0 100644 --- a/lib/vitest.workspace.ts +++ b/lib/vitest.workspace.ts @@ -18,7 +18,7 @@ export default defineWorkspace([ alias: [ { find: "@test-utils", - replacement: resolve(__dir, "src/__tests__/test-utils-react17.ts"), + replacement: resolve(__dir, "tests/test-utils-react17.ts"), }, { find: "@testing-library/react", @@ -46,7 +46,7 @@ export default defineWorkspace([ alias: [ { find: "@test-utils", - replacement: resolve(__dir, "src/__tests__/test-utils-react16.ts"), + replacement: resolve(__dir, "tests/test-utils-react16.ts"), }, { find: "@testing-library/react", diff --git a/rollup.config.js b/rollup.config.js index 4fd042d..967817b 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -20,7 +20,7 @@ export default { declaration: true, declarationDir: path.resolve(__dirname, "lib/dist"), rootDir: path.resolve(__dirname, "lib/src"), - sourceMap: true, + sourceMap: false, jsx: "react-jsx", }), filesize({ @@ -31,24 +31,20 @@ export default { { file: path.resolve(__dirname, "lib/dist/index.esm.js"), format: "esm", - sourcemap: true, }, { file: path.resolve(__dirname, "lib/dist/index.esm.min.js"), format: "esm", - sourcemap: true, plugins: [terser()], }, { file: path.resolve(__dirname, "lib/dist/index.cjs.js"), format: "cjs", - sourcemap: true, exports: "named", }, { file: path.resolve(__dirname, "lib/dist/index.cjs.min.js"), format: "cjs", - sourcemap: true, exports: "named", plugins: [terser()], },