Skip to content

Commit 772af7c

Browse files
committed
feat: hooks to use reactive values in react
1 parent d41a8bc commit 772af7c

File tree

13 files changed

+468
-0
lines changed

13 files changed

+468
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Aleksei Potsetsuev (Wroud)
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# @wroud/react-reactive-value
2+
3+
[![ESM-only package][package]][esm-info-url]
4+
[![NPM version][npm]][npm-url]
5+
6+
[package]: https://img.shields.io/badge/package-ESM--only-ffe536.svg
7+
[esm-info-url]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
8+
[npm]: https://img.shields.io/npm/v/@wroud/react-reactive-value.svg
9+
[npm-url]: https://npmjs.com/package/@wroud/react-reactive-value
10+
11+
@wroud/react-reactive-value is a lightweight library for managing reactive values in React applications. It provides a simple and efficient way to create and use reactive state that automatically triggers re-renders when values change.
12+
13+
## Features
14+
15+
- **React Integration**: Seamlessly integrates with React components for automatic updates.
16+
- **Performance Optimized**: Minimizes unnecessary re-renders through smart dependency tracking.
17+
- **TypeScript**: Written in TypeScript for type safety.
18+
- **Lightweight**: Small bundle size with zero dependencies.
19+
- [Pure ESM package][esm-info-url]
20+
21+
## Installation
22+
23+
Install via npm:
24+
25+
```sh
26+
npm install @wroud/react-reactive-value
27+
```
28+
29+
Install via yarn:
30+
31+
```sh
32+
yarn add @wroud/react-reactive-value
33+
```
34+
35+
## Documentation
36+
37+
For detailed usage and API reference, visit the [documentation site](https://wroud.dev).
38+
39+
## Example
40+
41+
```tsx
42+
import { useCreateReactiveValue, useReactiveValue } from "@wroud/react-reactive-value";
43+
import { useState, createContext, useContext } from "react";
44+
45+
// Create a counter store outside of components
46+
function createCounterStore() {
47+
// Private state
48+
let count = 0;
49+
const listeners = new Set<() => void>();
50+
51+
// Notify listeners when state changes
52+
const notifyListeners = () => {
53+
listeners.forEach(listener => listener());
54+
};
55+
56+
// Public API
57+
return {
58+
getValue: () => count,
59+
increment: () => {
60+
count++;
61+
notifyListeners();
62+
},
63+
subscribe: (onValueChange: () => void) => {
64+
listeners.add(onValueChange);
65+
return () => {
66+
listeners.delete(onValueChange);
67+
};
68+
}
69+
};
70+
}
71+
72+
// Create a context to share the reactive value
73+
const CounterContext = createContext(null);
74+
75+
// Provider component that creates the reactive value
76+
function CounterProvider({ children }) {
77+
// Create a singleton store
78+
const counterStore = useState(() => createCounterStore())[0];
79+
80+
// Create reactive value from the store
81+
const counter = useCreateReactiveValue(
82+
// Getter function
83+
() => counterStore.getValue(),
84+
// Subscribe function
85+
(onValueChange) => counterStore.subscribe(onValueChange),
86+
[counterStore] // Dependencies
87+
);
88+
89+
return (
90+
<CounterContext.Provider value={{ counter, counterStore }}>
91+
{children}
92+
</CounterContext.Provider>
93+
);
94+
}
95+
96+
// Consumer component that uses the reactive value
97+
function CounterDisplay() {
98+
// Get the reactive value from context
99+
const { counter } = useContext(CounterContext);
100+
101+
// Use the reactive value in the component
102+
const count = useReactiveValue(counter);
103+
104+
return <p>Count: {count}</p>;
105+
}
106+
107+
// Consumer component that updates the value
108+
function CounterControls() {
109+
// Get the store from context
110+
const { counterStore } = useContext(CounterContext);
111+
112+
return (
113+
<button onClick={() => counterStore.increment()}>
114+
Increment
115+
</button>
116+
);
117+
}
118+
119+
// Main component that composes the application
120+
function CounterApp() {
121+
return (
122+
<CounterProvider>
123+
<div>
124+
<CounterDisplay />
125+
<CounterControls />
126+
</div>
127+
</CounterProvider>
128+
);
129+
}
130+
```
131+
132+
## Changelog
133+
134+
All notable changes to this project will be documented in the [CHANGELOG](./CHANGELOG.md) file.
135+
136+
## License
137+
138+
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"name": "@wroud/react-reactive-value",
3+
"version": "0.0.1",
4+
"type": "module",
5+
"sideEffects": [],
6+
"license": "MIT",
7+
"author": "Wroud",
8+
"homepage": "https://wroud.dev/",
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/Wroud/foundation",
12+
"directory": "packages/@wroud/react-reactive-value"
13+
},
14+
"description": "A lightweight library for managing reactive values in React applications that efficiently triggers re-renders when values change",
15+
"keywords": [
16+
"react",
17+
"reactive",
18+
"state",
19+
"hooks",
20+
"performance",
21+
"typescript",
22+
"esm"
23+
],
24+
"exports": {
25+
".": "./lib/index.js",
26+
"./*": "./lib/*.js"
27+
},
28+
"scripts": {
29+
"ci:release": "ci release --prefix react-reactive-value-v",
30+
"ci:git-tag": "ci git-tag --prefix react-reactive-value-v",
31+
"ci:release-github": "ci release-github --prefix react-reactive-value-v",
32+
"test": "tests-runner",
33+
"test:ci": "CI=true yarn run test",
34+
"build": "tsc -b",
35+
"watch:tsc": "tsc -b -w",
36+
"dev": "concurrently \"npm:watch:*\"",
37+
"clear": "rimraf lib"
38+
},
39+
"files": [
40+
"package.json",
41+
"LICENSE",
42+
"README.md",
43+
"CHANGELOG.md",
44+
"lib",
45+
"!lib/**/*.d.ts.map",
46+
"!lib/**/*.test.js",
47+
"!lib/**/*.test.d.ts",
48+
"!lib/**/*.test.d.ts.map",
49+
"!lib/**/*.test.js.map",
50+
"!lib/tests",
51+
"!.tsbuildinfo"
52+
],
53+
"packageManager": "yarn@4.6.0",
54+
"devDependencies": {
55+
"@types/react": "^18.0.0 || ^19.0.0",
56+
"@vitest/coverage-v8": "^2",
57+
"@wroud/ci": "workspace:*",
58+
"@wroud/tests-runner": "workspace:*",
59+
"@wroud/tsconfig": "workspace:^",
60+
"react": "^18.0.0 || ^19.0.0",
61+
"rimraf": "^6",
62+
"typescript": "^5",
63+
"vitest": "^2"
64+
},
65+
"peerDependencies": {
66+
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
67+
}
68+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type IReactiveValueSubscribe<TArgs extends any[]> = (
2+
onValueChange: () => void,
3+
...args: TArgs
4+
) => () => void;
5+
6+
export interface IReactiveValue<T, TArgs extends any[]> {
7+
get(...args: TArgs): T;
8+
subscribe: IReactiveValueSubscribe<TArgs>;
9+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from "./useCreateReactiveValue.js";
2+
export * from "./useReactiveValue.js";
3+
export * from "./useReactiveValues.js";
4+
export * from "./IReactiveValue.js";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useMemo } from "react";
2+
import type {
3+
IReactiveValue,
4+
IReactiveValueSubscribe,
5+
} from "./IReactiveValue.js";
6+
7+
type NoInfer<T> = intrinsic;
8+
export function useCreateReactiveValue<T, TArgs extends any[]>(
9+
get: (...args: TArgs) => T,
10+
subscribe: NoInfer<IReactiveValueSubscribe<TArgs>> | null,
11+
deps: React.DependencyList,
12+
): IReactiveValue<T, TArgs> {
13+
const value = useMemo<IReactiveValue<T, TArgs>>(
14+
() => ({
15+
get,
16+
subscribe: subscribe ?? (() => () => {}),
17+
}),
18+
deps,
19+
);
20+
21+
return value;
22+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useMemo, useSyncExternalStore } from "react";
2+
import type { IReactiveValue } from "./IReactiveValue.js";
3+
4+
export function useReactiveValue<
5+
P extends IReactiveValue<any, any[]> | undefined,
6+
>(
7+
value: P,
8+
...args: P extends IReactiveValue<any, infer T> ? T : never
9+
): P extends IReactiveValue<infer T, any[]> ? T : undefined {
10+
const mappedValue = useMemo(
11+
() => ({
12+
cache: null as any,
13+
cacheInvalidated: true,
14+
subscribe: (onValueChange: () => void) => {
15+
if (value) {
16+
return value.subscribe(
17+
() => {
18+
mappedValue.cacheInvalidated = true;
19+
return onValueChange();
20+
},
21+
...args,
22+
);
23+
}
24+
25+
return () => {};
26+
},
27+
get: () => {
28+
if (mappedValue.cacheInvalidated) {
29+
mappedValue.cache = value?.get(...args);
30+
mappedValue.cacheInvalidated = false;
31+
}
32+
return mappedValue.cache;
33+
},
34+
}),
35+
[value, ...args],
36+
);
37+
return useSyncExternalStore(
38+
mappedValue.subscribe,
39+
mappedValue.get,
40+
mappedValue.get,
41+
);
42+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
2+
import type { IReactiveValue } from "./IReactiveValue.js";
3+
4+
export function useReactiveValues<
5+
P extends IReactiveValue<any, any[]> | undefined,
6+
>(
7+
value: P,
8+
onChange?: (
9+
...args: P extends IReactiveValue<any, infer T> ? T : never[]
10+
) => void,
11+
) {
12+
const onChangeRef = useRef(onChange);
13+
const valueChangeRef = useRef(() => {});
14+
const subscribeRef = useRef<Array<() => void>>([]);
15+
16+
onChangeRef.current = onChange;
17+
18+
for (const unsubscribe of subscribeRef.current) {
19+
unsubscribe();
20+
}
21+
subscribeRef.current = [];
22+
23+
const mappedValue = useMemo(
24+
() => ({
25+
cache: null as any,
26+
cacheInvalidated: true,
27+
subscribe: (onValueChange: () => void) => {
28+
valueChangeRef.current = onValueChange;
29+
30+
return () => {
31+
for (const unsubscribe of subscribeRef.current) {
32+
unsubscribe();
33+
}
34+
};
35+
},
36+
get: () => {
37+
if (mappedValue.cacheInvalidated) {
38+
mappedValue.cache = {};
39+
mappedValue.cacheInvalidated = false;
40+
}
41+
return mappedValue.cache;
42+
},
43+
}),
44+
[value],
45+
);
46+
47+
useSyncExternalStore(mappedValue.subscribe, mappedValue.get, mappedValue.get);
48+
49+
return useCallback(
50+
(
51+
...args: P extends IReactiveValue<any, infer T> ? T : never[]
52+
): P extends IReactiveValue<infer T, any[]> ? T : undefined => {
53+
if (value) {
54+
subscribeRef.current.push(
55+
value.subscribe(
56+
() => {
57+
mappedValue.cacheInvalidated = true;
58+
valueChangeRef.current();
59+
onChangeRef.current?.(...args);
60+
},
61+
...args,
62+
),
63+
);
64+
65+
return value.get(...args);
66+
}
67+
68+
return undefined as any;
69+
},
70+
[value],
71+
);
72+
}

0 commit comments

Comments
 (0)