react-impulse
v2.0.3
Published
The clean and natural React state management
Downloads
47
Maintainers
Readme
react-impulse
The clean and natural React state management.
# with yarn
yarn add react-impulse
# with npm
npm install react-impulse
Quick start
Impulse
is a box holding any value you want, even another Impulse
! All scoped
components that execute the Impulse#getValue
during the rendering phase enqueue re-render whenever the Impulse value updates.
import { Impulse, scoped } from "react-impulse"
const Input: React.FC<{
type: "email" | "password"
value: Impulse<string>
}> = scoped(({ scope, type, value }) => (
<input
type={type}
value={value.getValue(scope)}
onChange={(event) => value.setValue(event.target.value)}
/>
))
const Checkbox: React.FC<{
checked: Impulse<boolean>
children: React.ReactNode
}> = scoped(({ checked, children }) => (
<label>
<input
type="checkbox"
checked={checked.getValue(scope)}
onChange={(event) => checked.setValue(event.target.checked)}
/>
{children}
</label>
))
Once created, Impulses can travel thru your components, where you can set and get their values:
import { useImpulse, scoped } from "react-impulse"
const SignUp: React.FC = scoped(({ scope }) => {
const username = useImpulse("")
const password = useImpulse("")
const isAgreeWithTerms = useImpulse(false)
return (
<form>
<Input type="email" value={username} />
<Input type="password" value={password} />
<Checkbox checked={isAgreeWithTerms}>I agree with terms of use</Checkbox>
<button
type="button"
disabled={!isAgreeWithTerms.getValue(scope)}
onClick={() => {
tap((scope) => {
api.submitSignUpRequest({
username: username.getValue(scope),
password: password.getValue(scope),
})
})
}}
>
Sign Up
</button>
</form>
)
})
API
A core piece of the library is the Impulse
class - a box that holds value. The value might be anything you like as long as it does not mutate. The class instances are mutable by design, but other Impulses can use them as values.
Impulse.of
Impulse.of<T>(): Impulse<undefined | T>
Impulse.of<T>(
initialValue: T,
options?: ImpulseOptions<T>
): Impulse<T>
A static method that creates new Impulse.
[initialValue]
is an optional initial value. If not defined, the Impulse's value isundefined
but it still can specify the value's type.[options]
is an optionalImpulseOptions
object.[options.compare]
when not defined ornull
thenObject.is
applies as a fallback.
💡 The
useImpulse
hook helps to create and store anImpulse
inside a React component.
const count = Impulse.of(1) // Impulse<number>
const timeout = Impulse.of<number>() // Impulse<undefined | number>
Impulse.transmit
Impulse.transmit<T>(
getter: (scope: Scope) => T,
options?: TransmittingImpulseOptions<T>,
): ReadonlyImpulse<T>
Impulse.transmit<T>(
getter: ReadonlyImpulse<T> | ((scope: Scope) => T),
setter: Impulse<T> | ((value: T, scope: Scope) => void),
options?: TransmittingImpulseOptions<T>,
): Impulse<T>
getter
is either a source impulse or a function to read the transmitting value from a source.[setter]
either a destination impulse or is an optional function to write the transmitting value back to the source. When not defined, the Impulse is readonly.[options]
is an optionalTransmittingImpulseOptions
object.[options.compare]
when not defined ornull
thenObject.is
applies as a fallback.
A static method that creates a new transmitting Impulse. A transmitting Impulse is an Impulse that does not have its own value but reads it from an external source and writes it back to the source when the value changes. An external source is usually another Impulse or other Impulses.
const Drawer: React.FC<{
isOpen: Impulse<boolean>
children: React.ReactNode
}> = scoped(({ scope, isOpen, children }) => {
if (!isOpen.getValue(scope)) {
return null
}
return (
<div className="drawer">
{children}
<button type="button" onClick={() => isOpen.setValue(false)}>
Close
</button>
</div>
)
})
const ProductDetailsDrawer: React.FC<{
product: Impulse<undefined | Product>
}> = ({ product }) => {
const isOpen = useTransmittingImpulse(
(scope) => product.getValue(scope) != null,
[product],
(open) => {
if (!open) {
product.setValue(undefined)
}
},
)
return (
<Drawer isOpen={isOpen}>
<ProductDetails product={product} />
</Drawer>
)
}
const Checkbox: React.FC<{
checked: Impulse<boolean>
}> = scoped(({ scope, checked, children }) => (
<input
type="checkbox"
checked={checked.getValue(scope)}
onChange={(event) => checked.setValue(event.target.checked)}
/>
))
const Agreements: React.FC<{
isAgreeWithTermsOfUse: Impulse<boolean>
isAgreeWithPrivacy: Impulse<boolean>
}> = scoped(({ scope, isAgreeWithTermsOfUse, isAgreeWithPrivacy }) => {
const isAgreeWithAll = useTransmittingImpulse(
(scope) =>
isAgreeWithTermsOfUse.getValue(scope) &&
isAgreeWithPrivacy.getValue(scope),
[isAgreeWithTermsOfUse, isAgreeWithPrivacy],
(agree) => {
isAgreeWithTermsOfUse.setValue(agree)
isAgreeWithPrivacy.setValue(agree)
},
)
return (
<div>
<Checkbox checked={isAgreeWithTermsOfUse}>
I agree with terms of use
</Checkbox>
<Checkbox checked={isAgreeWithPrivacy}>
I agree with privacy policy
</Checkbox>
<hr />
<Checkbox checked={isAgreeWithAll}>I agree with all</Checkbox>
</div>
)
})
It can also transmit another store's value, such as React.useState
, redux
, URL, etc.
const Counter: React.FC = () => {
const [count, setCount] = React.useState(0)
const countImpulse = useTransmittingImpulse(
() => count,
[count],
(value) => setCount(value),
)
return (
<button type="button" onClick={() => countImpulse.setValue((x) => x + 1)}>
{count}
</button>
)
}
import { useSelector, useDispatch } from "react-redux"
const Counter: React.FC = () => {
const count = useSelector((state) => state.count)
const dispatch = useDispatch()
const countImpulse = useTransmittingImpulse(
() => count,
[count],
(value) => dispatch({ type: "SET_COUNT", payload: value }),
)
return (
<button type="button" onClick={() => countImpulse.setValue((x) => x + 1)}>
{count}
</button>
)
}
import { useSearchParams } from "react-router-dom"
const PageNavigation: React.FC = () => {
const [{ page_index = 1 }, setSearchParams] = useSearchParams()
const page = useTransmittingImpulse(
() => page_index,
[page_index],
(index) => {
setSearchParams({ page_index: index })
},
)
return (
<button type="button" onClick={() => page.setValue((x) => x + 1)}>
Go to the next page
</button>
)
}
💡 The
useTransmittingImpulse
hook helps to create and store a transmittingImpulse
inside a React component.
Impulse.isImpulse
Impulse.isImpulse<T, Unknown = unknown>(
input: Unknown | Impulse<T>,
): input is Impulse<T>
Impulse.isImpulse<T, Unknown = unknown>(
scope: Scope,
check: (value: unknown) => value is T,
input: Unknown | Impulse<T>,
): input is Impulse<T>
A static method that checks whether the input
is an Impulse
instance. If the check
function is provided, it checks the Impulse's value to match the check
function.
Impulse#getValue
Impulse<T>#getValue(scope: Scope): T
Impulse<T>#getValue<R>(scope: Scope, select: (value: T) => R): R
An Impulse
instance's method that returns the current value.
scope
isScope
that tracks the Impulse value changes.[select]
is an optional function that applies to the current value before returning.
tap((scope) => {
const count = Impulse.of(3)
count.getValue(scope) // === 3
count.getValue(scope, (x) => x > 0) // === true
})
Impulse#setValue
Impulse<T>#setValue(
valueOrTransform: T | ((currentValue: T, scope: Scope) => T),
): void
An Impulse
instance's method to update the value.
valueOrTransform
is the new value or a function that transforms the current value.
tap((scope) => {
const isActive = Impulse.of(false)
isActive.setValue((x) => !x)
isActive.getValue(scope) // true
isActive.setValue(false)
isActive.getValue(scope) // false
})
💡 If
valueOrTransform
argument is a function it acts asbatch
.
💬 The method returns
void
to emphasize thatImpulse
instances are mutable.
Impulse#clone
Impulse<T>#clone(
options?: ImpulseOptions<T>,
): Impulse<T>
Impulse<T>#clone(
transform?: (value: T, scope: Scope) => T,
options?: ImpulseOptions<T>,
): Impulse<T>
An Impulse
instance's method for cloning an Impulse. When cloning a transmitting Impulse, the new Impulse is not transmitting, meaning that it does not read nor write the value from/to the external source.
[transform]
is an optional function that applies to the current value before cloning. It might be handy when cloning mutable values.[options]
is optionalImpulseOptions
object.[options.compare]
when not defined it uses thecompare
function from the origin Impulse, whennull
theObject.is
function applies to compare the values.
const immutable = Impulse.of({
count: 0,
})
const cloneOfImmutable = immutable.clone()
const mutable = Impulse.of({
username: Impulse.of(""),
blacklist: new Set(),
})
const cloneOfMutable = mutable.clone((current) => ({
username: current.username.clone(),
blacklist: new Set(current.blacklist),
}))
Scope
Scope
is a bridge that connects Impulses with host components. It tracks the Impulses' value changes and enqueues re-renders of the host components that read the Impulses' values. The only way to read an Impulse's value is to call the Impulse#getValue
method with Scope
passed as the first argument. The following are the primary ways to create a Scope
:
scoped
components provide thescope: Scope
property. Thescope
can be used inside the entire component's body.useScoped
hook provides thescope
argument. It can be used in custom hooks or inside components to narrow down the re-rendering scope.subscribe
function provides thescope
argument. It is useful outside of the React world.batch
function provides thescope
argument. Use it to optimize multiple Impulses updates or to access the Impulses' values inside async operations.untrack
function provides thescope
argument. Use it when you need to read Impulses' values without reactivity.useScopedCallback
,useScopedMemo
,useScopedEffect
,useScopedLayoutEffect
hooks provide thescope
argument. They are enchanted versions of the React hooks that provide thescope
argument as the first argument.
scoped
function scoped<TProps>(component: React.FC<PropsWithScope<TProps>>): React.FC<PropsWithoutScope<TProps>>
The scoped
function creates a React component that provides the scope: Scope
property and subscribes to all Impulses calling the Impulse#getValue
method during the rendering phase of the component.
The Counter
component below enqueues a re-render whenever the count
's value changes, for instance, when the Counter
's button clicks:
const Counter: React.FC<{
count: Impulse<number>
}> = scoped(({ scope, count }) => (
<button onClick={() => count.setValue((x) => x + 1)}>
{count.getValue(scope)}
</button>
))
But if a component defines an Impulse, passes it thru, or calls the Impulse#getValue
method outside of the rendering phase (ex: inside an onClick
handler), then it does not subscribe to the Impulse changes.
Here the SumOfTwo
component defines two Impulses, passes them further to the Counter
s components, and calls Impulse#getValue
inside the button.onClick
handler. It is not necessary to use the scoped
function in that case:
const SumOfTwo: React.FC = () => {
const firstCounter = useImpulse(0)
const secondCounter = useImpulse(0)
return (
<div>
<Counter count={firstCounter} />
<Counter count={secondCounter} />
<button
onClick={() => {
batch((scope) => {
const sum =
firstCounter.getValue(scope) + secondCounter.getValue(scope)
console.log("Sum of two is %d", sum)
firstCounter.setValue(0)
secondCounter.setValue(0)
})
}}
>
Save and reset
</button>
</div>
)
}
With or without wrapping the component around the scoped
HOC, The SumOfTwo
component will never re-render due to either firstCounter
or secondCounter
updates, but still, it can read and write their values inside the onClick
listener.
scoped.memo
Alias for
React.memo(scoped(Component))
// equals to
scoped.memo(Component)
scoped.forwardRef
Alias for
React.forwardRef(scoped(Component))
// equals to
scoped.forwardRef(Component)
scoped.memo.forwardRef
and scoped.forwardRef.memo
Aliases for
React.memo(React.forwardRef(scoped(Component)))
// equals to
scoped.memo.forwardRef(Component)
scoped.forwardRef.memo(Component)
useImpulse
function useImpulse<T>(): Impulse<undefined | T>
function useImpulse<T>(
valueOrInitValue: T | ((scope: Scope) => T),
options?: ImpulseOptions<T>
): Impulse<T>
[valueOrInitValue]
is an optional value used during the initial render. If the initial value is the result of an expensive computation, you may provide a function instead, which will be executed only on the initial render. If not defined, the Impulse's value isundefined
but it still can specify the value's type.[options]
is optionalImpulseOptions
object.[options.compare]
when not defined ornull
thenObject.is
applies as a fallback.
A hook that initiates a stable (never changing) Impulse. It's value can be changed with the Impulse#setValue
method though.
💬 The initial value is disregarded during subsequent re-renders but compare function is not - it uses the latest function passed to the hook.
💡 There is no need to memoize
options.compare
function. The hook does it internally.
const count = useImpulse(1) // Impulse<number>
const timeout = useImpulse<number>() // Impulse<undefined | number>
const tableSum = useImpulse(() => {
// the function body runs only once on the initial render
return bigTable
.flatMap((wideRow) => wideRow.map((int) => int * 2))
.reduce((acc, x) => acc + x, 0)
}) // Impulse<number>
// the function provides scope to extract the initial value from other Impulses
const countDouble = useImpulse((scope) => 2 * count.getValue(scope)) // Impulse<number>
useTransmittingImpulse
function useTransmittingImpulse<T>(
getter: (scope: Scope) => T,
dependencies: DependencyList,
options?: TransmittingImpulseOptions<T>,
): ReadonlyImpulse<T>
function useTransmittingImpulse<T>(
getter: ReadonlyImpulse<T> | ((scope: Scope) => T),
dependencies: DependencyList,
setter: Impulse<T> | ((value: T, scope: Scope) => void),
options?: TransmittingImpulseOptions<T>,
): Impulse<T>
getter
is either a source impulse or a function to read the transmitting value from a source.dependencies
an array of values triggering the re-read of the transmitting value.[setter]
either a destination impulse or is an optional function to write the transmitting value back to the source. When not defined, the Impulse is readonly.[options]
is an optionalTransmittingImpulseOptions
object.[options.compare]
when not defined ornull
thenObject.is
applies as a fallback.
A hook that initialize a stable (never changing) transmitting Impulse. Look at the Impulse.transmit
method for more details and examples.
💡 There is no need to memoize neither
getter
,setter
, noroptions.compare
functions. The hook does it internally.
useScoped
function useScoped<TValue>(impulse: ReadonlyImpulse<TValue>): TValue
function useScoped<T>(
factory: (scope: Scope) => T,
dependencies?: DependencyList,
options?: UseScopedOptions<T>
): T
impulse
is anImpulse
instance to read the value from.factory
is a function that providesScope
as the first argument and subscribes to all Impulses calling theImpulse#getValue
method inside the function.dependencies
is an optional array of dependencies of thefactory
function. If not defined, thefactory
function is called on every render.[options]
is an optionalUseScopedOptions
object.
The useScoped
hook is an alternative to the scoped
function. It either executes the factory
function whenever any of the scoped Impulses' value update or reads the impulse
value but enqueues a re-render only when the resulting value is different from the previous.
Custom hooks can use useScoped
for reading and transforming the Impulses' values, so the host component doesn't need to wrap around the scoped
HOC:
const useSumAllAndMultiply = ({
multiplier,
counts,
}: {
multiplier: Impulse<number>
counts: Impulse<Array<Impulse<number>>>
}): number => {
return useScoped((scope) => {
const sumAll = counts
.getValue(scope)
.map((count) => count.getValue(scope))
.reduce((acc, x) => acc + x, 0)
return multiplier.getValue(scope) * sumAll
})
}
Components can scope watched Impulses to reduce re-rendering:
const Challenge: React.FC = () => {
const count = useImpulse(0)
// the component re-renders only once when the `count` is greater than 5
const isMoreThanFive = useScoped((scope) => count.getValue(scope) > 5)
return (
<div>
<Counter count={count} />
{isMoreThanFive && <p>You did it 🥳</p>}
</div>
)
}
💬 The
factory
function is only for reading the Impulses' values. It should never callImpulse.of
,Impulse#clone
, orImpulse#setValue
methods inside.
💡 Keep in mind that the
factory
function acts as a "reader" so you'd like to avoid heavy computations inside it. Sometimes it might be a good idea to pass a factory result to a separated memoization hook. The same is true for thecompare
function - you should choose wisely between avoiding extra re-renders and heavy comparisons.
💡 There is no need to memoize
options.compare
function. The hook does it internally.
useScopedMemo
function useScopedMemo<T>(
factory: (scope: Scope) => T,
dependencies: DependencyList,
): T
factory
is a function that providesScope
as the first argument and calculates a valueT
whenever any of thedependencies
' values change.dependencies
is an array of values used in thefactory
function.
The hook is an enchanted React.useMemo
hook.
useScopedCallback
function useScopedCallback<TArgs extends ReadonlyArray<unknown>, TResult>(
callback: (scope: Scope, ...args: TArgs) => TResult,
dependencies: DependencyList,
): (...args: TArgs) => TResult
callback
is a function to memoize, the memoized function injectsScope
as the first argument and updates whenever any of thedependencies
values change.dependencies
is an array of values used in thecallback
function.
The hook is an enchanted React.useCallback
hook.
useScopedEffect
function useScopedEffect(
effect: (scope: Scope) => void | VoidFunction,
dependencies?: DependencyList,
): void
effect
is a function that providesScope
as the first argument and runs whenever any of thedependencies
' values change. Can return a cleanup function to cancel running side effects.[dependencies]
is an optional array of values used in theeffect
function.
The hook is an enchanted React.useEffect
hook.
useScopedLayoutEffect
The hook is an enchanted React.useLayoutEffect
hook. Acts similar way as useScopedEffect
.
~~useScopedInsertionEffect
~~
There is no enchanted version of the React.useInsertionEffect
hook due to backward compatibility with React from v16.12.0
. The workaround is to use the native React.useInsertionEffect
hook with the values extracted beforehand:
const usePrintSum = (left: number, right: Impulse<number>): void => {
const rightValue = useScoped((scope) => right.getValue(scope))
React.useInsertionEffect(() => {
console.log("sum is %d", left + rightValue)
}, [left, rightValue])
}
batch
function batch(execute: (scope: Scope) => void): void
The batch
function is a helper to optimize multiple Impulses updates. It provides a Scope
to the execute
function so it is useful when an async operation accesses the Impulses' values.
execute
is a function that executes multipleImpulse#setValue
calls at ones.
const SumOfTwo: React.FC<{
left: Impulse<number>
right: Impulse<number>
}> = scoped(({ scope, left, right }) => (
<div>
<span>Sum is: {left.getValue(scope) + right.getValue(scope)}</span>
<button
onClick={() => {
batch((scope) => {
console.log(
"resetting the sum %d",
left.getValue(scope) + right.getValue(scope),
)
// enqueues 1 re-render instead of 2 🎉
left.setValue(0)
right.setValue(0)
})
}}
>
Reset
</button>
</div>
))
tap
Alias for batch
.
untrack
function untrack<TResult>(factory: (scope: Scope) => TResult): TResult
function untrack<TValue>(impulse: ReadonlyImpulse<TValue>): TValue
The untrack
function is a helper to read Impulses' values without reactivity. It provides a Scope
to the factory
function and returns the result of the function. Acts as batch
.
subscribe
function subscribe(listener: (scope: Scope) => void | VoidFunction): VoidFunction
listener
is a function that providesScope
as the first argument and subscribes to changes of allImpulse
instances that call theImpulse#getValue
method inside thelistener
. Iflistener
returns a function then it will be called before the nextlistener
call.
Returns a cleanup function that unsubscribes the listener
. The listener
calls first time synchronously when subscribe
is called.
It is useful for subscribing to changes of multiple Impulses at once:
const impulse_1 = new Impulse(1)
const impulse_2 = new Impulse(2)
const impulse_3 = new Impulse("calculating...")
const unsubscribe = subscribe((scope) => {
if (impulse_1.getValue(scope) > 1) {
const sum = impulse_2.getValue(scope) + impulse_3.getValue(scope)
impulse_3.setValue(`done: ${sum}`)
}
})
In the example above the listener
will not react on the impulse_2
updates until the impulse_1
value is greater than 1
. The impulse_3
updates will never trigger the listener
, because the impulse_3.getValue(scope)
is not called inside the listener
.
💬 The
subscribe
function is the only function that injectsScope
to theImpulse#toJSON()
andImpulse#toString()
methods because the methods do not have access to thescope
:const counter = Impulse.of({ count: 0 }) subscribe(() => { console.log(JSON.stringify(counter)) }) // console: {"count":0} counter.setValue(2) // console: {"count":2}
type ReadonlyImpulse
A type alias for Impulse
that does not have the Impulse#setValue
method. It might be handy to store some value inside an Impulse, so the value change trigger a host component re-render only if the component reads the value from the Impulse.
interface ImpulseOptions
interface ImpulseOptions<T> {
compare?: null | Compare<T>
}
[compare]
is an optionalCompare
function that determines whether or not a new Impulse's value replaces the current one. In many cases specifying the function leads to better performance because it prevents unnecessary updates. But keep an eye on the balance between the performance and the complexity of the function - sometimes it might be better to replace the value without heavy comparisons.
interface TransmittingImpulseOptions
interface TransmittingImpulseOptions<T> {
compare?: null | Compare<T>
}
[compare]
is an optionalCompare
function that determines whether or not a transmitting value changes when reading it from an external source.tap((scope) => { const source = Impulse.of(1) const counter_1 = Impulse.transmit( // the getter function creates a new object on every read () => ({ count: source.getValue(scope) }), ({ count }) => source.setValue(count), ) counter_1.getValue(scope) // { count: 1 } counter_1.getValue(scope) === counter_1.getValue(scope) // false // let's transmit the value but with compare function defined const counter_1 = Impulse.transmit( // the getter function creates a new object on every read // but if they are compared equal, the transmitting value is not changed (scope) => ({ count: source.getValue(scope) }), ({ count }) => source.setValue(count), { compare: (left, right) => left.count === right.count, }, ) counter_2.getValue(scope) // { count: 1 } counter_2.getValue(scope) === counter_2.getValue(scope) // true })
interface UseScopedOptions
interface UseScopedOptions<T> {
compare?: null | Compare<T>
}
[compare]
is an optionalCompare
function that determines whether or not the factory result is different. If the factory result is different, a host component re-renders. In many cases specifying the function leads to better performance because it prevents unnecessary updates.
type Compare
type Compare<T> = (left: T, right: T, scope: Scope) => boolean
A function that compares two values and returns true
if they are equal. Depending on the type of the values it might be reasonable to use a custom compare function such as shallow-equal or deep-equal.
ESLint
Want to see ESLint suggestions for the dependencies? Add the hook name to the ESLint rule override:
{
"react-hooks/exhaustive-deps": [
"error",
{
"additionalHooks": "(useScoped(|Effect|LayoutEffect|Memo|Callback)|useTransmittingImpulse)"
}
]
}
ESLint can also help validate unnecessary and abusive hooks/HOCs usage:
{
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression:has(:matches(.callee, .callee.property)[name=/(useTransmittingImpulse|use(Scoped)?(|Memo|Callback|Effect|LayoutEffect))/]) > .arguments:nth-child(2) > [name='scope']",
"message": "The `scope` dependency changes on each component's re-render. Please use `scope` provided as the first argument in the `useScoped*` hooks."
},
{
"selector": "CallExpression[callee.name=/useScoped(|Memo|Callback|Effect|LayoutEffect)/] > .arguments:nth-child(1)[params.length=0]",
"message": "The `scope` argument of the hook effect is not used, consider using React effect hooks instead of Impulse scoped hooks."
},
{
"selector": "CallExpression:has(:matches(.callee, .callee .object)[name='scoped']) > .arguments:nth-child(1) > .params:nth-child(1):not(:has(.properties[key.name='scope']))",
"message": "The `scope` prop is not used, consider using the component without wrapping it in the `scoped` HOC."
},
{
"selector": "CallExpression:has(:matches(.callee, .callee .object)[name='scoped']) > .arguments:nth-child(1) > .params:nth-child(1) > .properties[key.name='scope'] > .value[name!='scope']",
"message": "Do not rename the `scope` prop created by the `scoped` HOC."
}
]
}