Skip to content

Commit ba42691

Browse files
committed
feat: adds rv.infer and useRvEffect functions
1 parent 04b6935 commit ba42691

File tree

10 files changed

+233
-1
lines changed

10 files changed

+233
-1
lines changed

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default [
1515
'@typescript-eslint/consistent-type-definitions': 'off',
1616
'@typescript-eslint/consistent-type-assertions': 'off',
1717
'@typescript-eslint/unbound-method': 'off',
18+
'@typescript-eslint/no-namespace': 'off',
1819
},
1920
},
2021
{

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-rv",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"description": "react-rv is a lightweight and efficient state management library for React that allows you to create reactive variables and subscribe to them with minimal overhead.",
55
"files": [
66
"dist"

readme.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ react-rv is a lightweight and efficient state management library for React that
99
- **Efficient**: Only re-renders components that are subscribed to reactive variables.
1010
- **Flexible**: Works inside and outside React components.
1111
- **Custom equality checks**: Define your own comparison logic to avoid redundant updates.
12+
- **Stateless update subscriptions**: If you only need to read state updates without updating your component.
1213
- **TypeScript Support**: Fully typed for better developer experience.
1314

1415
## Installation
@@ -80,13 +81,35 @@ const Component = () => {
8081
}
8182
```
8283

84+
- Listening to a state change without updating current component
85+
86+
```tsx
87+
const Component = () => {
88+
// the callback is going to be called when a variable changes,
89+
// but this component won't be rerendered
90+
useRvEffect(darkTheme, isEnabled => {
91+
console.log(isEnabled)
92+
})
93+
94+
return ...
95+
}
96+
```
97+
98+
- Infer the type of the reactive variable
99+
100+
```ts
101+
const darkTheme = rv(false)
102+
type DarkTheme = rv.infer<typeof darkTheme> // boolean
103+
```
104+
83105
## Why `react-rv` Over React Context?
84106
85107
- **More granual state splitting**: React Context would require to create a separate context for every piece of state you'd use, which would also force you to wrap the app with providers for each react context.
86108
If you don't do that and decide to create one big state, it would involve unnecessary re-renders in components that only need one field of the state.
87109
- **Simpler API**: No need for providers, reducers, or complex state logic.
88110
- **No need for state setters**: The variable that's returned by `rv()` function is already a getter/setter itself.
89111
- **Usage outside React component tree**: Since the variable itself is a setter as well, you can use it outside react component tree. For example in your util functions that's defined outside react components.
112+
- **State listeners**: Allows you to listen to state changes without rerendering a current component. Useful for side-effects.
90113
91114
#### React Context example
92115

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
export { rv } from './rv'
44
export { useRv } from './use-rv'
5+
export { useRvEffect } from './use-rv-effect'
56

67
export type * from './types'

src/rv.spec-d.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,34 @@ describe('rv function', () => {
157157
val(3)
158158
})
159159
})
160+
161+
describe('rv.infer method', () => {
162+
it('infers a primitive type of rv', () => {
163+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
164+
const num = rv(3)
165+
166+
type Inferred = rv.infer<typeof num>
167+
168+
expectTypeOf<Inferred>().toBeNumber()
169+
})
170+
171+
it('infers a complex type of rv', () => {
172+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
173+
const obj = rv({ name: '', age: 0, data: {} })
174+
175+
type Inferred = rv.infer<typeof obj>
176+
177+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
178+
expectTypeOf<Inferred>().toMatchObjectType<{ name: string; age: number; data: {} }>()
179+
})
180+
181+
it('infers as never and throws a type error if it is not a reactive variable', () => {
182+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
183+
const justNumber = 30
184+
185+
// @ts-expect-error should throw an error since it's not a reactive variable
186+
type Inferred = rv.infer<typeof justNumber>
187+
188+
expectTypeOf<Inferred>().toBeNever()
189+
})
190+
})

src/rv.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ describe('rv function', () => {
5252
expect(listener).toHaveBeenCalledExactlyOnceWith(3, 2)
5353
})
5454

55+
it('unsubscribes a listener', () => {
56+
const val = rv(2)
57+
const listener = vi.fn()
58+
59+
val.on(listener)
60+
val.off(listener)
61+
62+
val(3)
63+
expect(listener).not.toHaveBeenCalled()
64+
})
65+
5566
it('allows to pass the default listener', () => {
5667
const listener = vi.fn(() => {})
5768

src/rv.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,18 @@ export function rv<T>(val: T, opts?: RvInitOptions<T>): Rv<T> {
108108
*/
109109
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
110110
rv.fn = <T>(init: () => T, options?: RvInitOptions<T>) => rv(init(), options)
111+
112+
export namespace rv {
113+
/**
114+
* Infers the type of the reactive variable.
115+
*
116+
* @example
117+
* ```ts
118+
* const darkMode = rv(false)
119+
*
120+
* type DarkMode = rv.infer<typeof darkMode> // infers as boolean
121+
* ```
122+
*/
123+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
124+
export type infer<T extends Rv<any>> = T extends Rv<infer X> ? X : never
125+
}

src/use-rv-effect.spec-d.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { renderHook } from '@testing-library/react'
2+
import { useRvEffect } from './use-rv-effect'
3+
import { rv } from './rv'
4+
5+
describe('useRvEffect hook', () => {
6+
it('should infer payload type correctly', () => {
7+
const counter = rv(0)
8+
9+
renderHook(() => {
10+
useRvEffect(counter, (newVal, oldVal) => {
11+
expectTypeOf(newVal).toBeNumber()
12+
expectTypeOf(oldVal).toBeNumber()
13+
})
14+
})
15+
16+
const stringVar = rv<'one' | 'two'>('one')
17+
18+
renderHook(() => {
19+
useRvEffect(stringVar, (newVal, oldVal) => {
20+
expectTypeOf(newVal).toEqualTypeOf<'one' | 'two'>()
21+
expectTypeOf(oldVal).toEqualTypeOf<'one' | 'two'>()
22+
})
23+
})
24+
})
25+
})

src/use-rv-effect.spec.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { renderHook, act } from '@testing-library/react'
2+
import { useRvEffect } from './use-rv-effect'
3+
import { rv } from './rv'
4+
5+
describe('useRvEffect hook', () => {
6+
it('should listen to rv updates', () => {
7+
const handler = vi.fn()
8+
const counter = rv(0)
9+
10+
renderHook(() => {
11+
useRvEffect(counter, handler)
12+
})
13+
14+
act(() => {
15+
counter(1)
16+
})
17+
18+
expect(handler).toHaveBeenCalledWith(1, 0)
19+
expect(handler).toHaveBeenCalledTimes(1)
20+
})
21+
22+
it(`should not be triggered if state didn't change`, () => {
23+
const handler = vi.fn()
24+
const counter = rv(0)
25+
26+
renderHook(() => {
27+
useRvEffect(counter, handler)
28+
})
29+
30+
// no change
31+
act(() => {
32+
counter(0)
33+
})
34+
35+
expect(handler).not.toHaveBeenCalled()
36+
})
37+
38+
it('always has the most recent scope', () => {
39+
const counter = rv(0)
40+
const values: number[] = []
41+
42+
const { rerender } = renderHook(
43+
({ multiplier }: { multiplier: number }) => {
44+
useRvEffect(counter, v => {
45+
values.push(v * multiplier)
46+
})
47+
},
48+
{ initialProps: { multiplier: 1 } },
49+
)
50+
51+
act(() => {
52+
counter(2)
53+
})
54+
55+
rerender({ multiplier: 10 })
56+
57+
act(() => {
58+
counter(3)
59+
})
60+
61+
expect(values).toEqual([2, 30])
62+
})
63+
64+
it('should unsubscribe on unmount', () => {
65+
const handler = vi.fn()
66+
const counter = rv(0)
67+
68+
const { unmount } = renderHook(() => {
69+
useRvEffect(counter, handler)
70+
})
71+
72+
unmount()
73+
74+
act(() => {
75+
counter(1)
76+
})
77+
78+
expect(handler).not.toHaveBeenCalled()
79+
})
80+
81+
it("doesn't trigger re-render when rv updates", () => {
82+
const counter = rv(0)
83+
const renderSpy = vi.fn()
84+
85+
renderHook(() => {
86+
renderSpy()
87+
useRvEffect(counter, () => {})
88+
})
89+
90+
expect(renderSpy).toHaveBeenCalledTimes(1)
91+
92+
act(() => {
93+
counter(1)
94+
})
95+
96+
expect(renderSpy).toHaveBeenCalledTimes(1) // no re-render
97+
})
98+
})

src/use-rv-effect.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useRef } from 'react'
2+
import type { Listener, Rv } from './types'
3+
4+
/**
5+
* React hook to subscribe to updates of a reactive variable.
6+
*
7+
* Note: callback is always using current scope.
8+
*
9+
* @example
10+
* ```ts
11+
* const counter = rv(0)
12+
*
13+
* useRvEffect(counter, (newVal, oldVal) => {
14+
* console.table({ newVal, oldVal })
15+
* })
16+
* ```
17+
*
18+
* @param rv - The reactive variable to subscribe to.
19+
* @param f - A callback function triggered whenever the reactive variable is updated.
20+
* The first and second arguments are the new and the old value of this `rv`.
21+
*/
22+
export function useRvEffect<T>(rv: Rv<T>, f: Listener<T>): void {
23+
const fnRef = useRef(f)
24+
fnRef.current = f
25+
26+
useEffect(() => rv.on((...args) => fnRef.current(...args)), [rv])
27+
}

0 commit comments

Comments
 (0)