react-sweety
v3.0.2
Published
The clean and natural React state management
Downloads
8
Maintainers
Readme
THE LIBRARY HAS CHANGED NPM NAME FROM react-sweety
TO react-impulse
.
react-sweety
The clean and natural React state management.
# with yarn
yarn add react-sweety
# with npm
npm install react-sweety
Quick start
Sweety
is a box holding any value you want, even another Sweety
! All watch
ed components that execute the Sweety#getState
during the rendering phase enqueue re-render whenever the Sweety
instance's state updates.
import { Sweety, watch } from "react-sweety"
const Input: React.FC<{
type: "email" | "password"
value: Sweety<string>
}> = watch(({ type, value }) => (
<input
type={type}
value={value.getState()}
onChange={(event) => value.setState(event.target.value)}
/>
))
const Checkbox: React.FC<{
checked: Sweety<boolean>
children: React.ReactNode
}> = watch(({ checked, children }) => (
<label>
<input
type="checkbox"
checked={checked.getState()}
onChange={(event) => checked.setState(event.target.checked)}
/>
{children}
</label>
))
Once created, Sweety
instances can travel thru your components, where you can set and get their states:
import { useSweety, watch } from "react-sweety"
const SignUp: React.FC = watch(() => {
const username = useSweety("")
const password = useSweety("")
const isAgreeWithTerms = useSweety(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.getState()}
onClick={() => {
api.submitSignUpRequest({
username: username.getState(),
password: password.getState(),
})
}}
>
Sign Up
</button>
</form>
)
})
Demos
- Todo MVC - an implementation of todomvc.com template.
- Obstacle maze - an application to build and solve mazes with source code at GitHub.
- Catanstat - an application to track Catan game statistics with source code at GitHub.
API
A core piece of the library is the Sweety
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 Sweety
instances can use them as values.
Sweety.of
Sweety.of<T>(
initialState: T,
compare?: null | Compare<T>
): Sweety<T>
A static method that creates a new Sweety
instance.
initialState
is the initial state.[compare]
is an optionalCompare
function applied asSweety#compare
. When not defined ornull
thenObject.is
applies as a fallback.
💡 The
useSweety
hook helps to create and store aSweety
instance inside a React component.
Sweety#getState
Sweety<T>#getState(): T
Sweety<T>#getState<R>(select: (state: T) => R): R
A Sweety
instance's method that returns the current state.
[select]
is an optional function that applies to the current state before returning.
const count = Sweety.of(3)
count.getState() // === 3
count.getState((x) => x > 0) // === true
Sweety#setState
Sweety<T>#setState(
stateOrTransform: React.SetStateAction<T>,
compare?: null | Compare<T>
): void
A Sweety
instance's method to update the state. All listeners registered via the Sweety#subscribe
method execute whenever the instance's state updates.
stateOrTransform
is the new state or a function that transforms the current state into the new state.[compare]
is an optionalCompare
function applied for this call only. When not defined theSweety#compare
function of the instance will be used. Whennull
theObject.is
function applies to compare the states.
const isActive = Sweety.of(false)
isActive.setState((x) => !x)
isActive.getState() // true
isActive.setState(false)
isActive.getState() // false
💡 If
stateOrTransform
argument is a function it acts asbatch
.
💬 The method returns
void
to emphasize thatSweety
instances are mutable.
Sweety#clone
Sweety<T>#clone(
transform?: (state: T) => T,
compare?: null | Compare<T>
): Sweety<T>
A Sweety
instance's method for cloning a Sweety
instance.
[transform]
is an optional function that applies to the current state before cloning. It might be handy when cloning a state that contains mutable values.[compare]
is an optionalCompare
function applied asSweety#compare
. When not defined, it uses theSweety#compare
function from the origin. Whennull
theObject.is
function applies to compare the states.
const immutable = Sweety.of({
count: 0,
})
const cloneOfImmutable = immutable.clone()
const mutable = Sweety.of({
counters: [Sweety.of(0), Sweety.of(1)],
})
const cloneOfMutable = mutable.clone(({ counters }) => ({
counters: counters.map((counter) => counter.clone()),
}))
Sweety#compare
Sweety<T>#compare: Compare<T>
The Compare
function compares the state of a Sweety
instance with the new state given via Sweety#setState
. Whenever the function returns true
, neither the state change nor it notifies the listeners subscribed via Sweety#subscribe
.
Sweety#subscribe
Sweety<T>#subscribe(listener: VoidFunction): VoidFunction
A Sweety
instance's method that subscribes to the state's updates caused by calling Sweety#setState
. Returns a cleanup function that unsubscribes the listener
.
listener
is a function that subscribes to the updates.
const count = Sweety.of(0)
const unsubscribe = count.subscribe(() => {
console.log("The count is %d", count.getState())
})
count.setState(10) // console: "The count is 10"
unsubscribe()
count.setState(20) // ...
💬 You'd like to avoid using the method in your application because it's been designed for convenient use in the exposed hooks and the
watch
HOC.
watch
function watch<TProps>(component: React.FC<TProps>): React.FC<TProps>
The watch
function creates a React component that subscribes to all Sweety
instances calling the Sweety#getState
method during the rendering phase of the component.
The Counter
component below enqueues a re-render whenever the count
's state changes, for instance, when the Counter
's button clicks:
const Counter: React.FC<{
count: Sweety<number>
}> = watch(({ count }) => (
<button onClick={() => count.setState((x) => x + 1)}>
{count.getState()}
</button>
))
But if a component defines a Sweety
instance, passes it thru, or calls the Sweety#getState
method outside of the rendering phase (ex: as part of event listeners handlers), then it does not subscribe to the Sweety
instances changes.
Here the SumOfTwo
component defines two Sweety
instances, passes them further to the Counter
s components, and calls Sweety#getState
inside the button.onClick
handler. It is optional to use the watch
function in that case:
const SumOfTwo: React.FC = () => {
const firstCounter = useSweety(0)
const secondCounter = useSweety(0)
return (
<div>
<Counter count={firstCounter} />
<Counter count={secondCounter} />
<button
onClick={() => {
const sum = firstCounter.getState() + secondCounter.getState()
console.log("Sum of two is %d", sum)
firstCounter.setState(0)
secondCounter.setState(0)
}}
>
Save and reset
</button>
</div>
)
}
With or without wrapping the component around the watch
HOC, The SumOfTwo
component will never re-render due to either firstCounter
or secondCounter
updates, but still, it can read and write their states inside the onClick
listener.
watch.memo
Alias for
React.memo(watch(/* */))
// equals to
watch.memo(/* */)
watch.forwardRef
Alias for
React.forwardRef(watch(/* */))
// equals to
watch.forwardRef(/* */)
watch.memo.forwardRef
and watch.forwardRef.memo
Aliases for
React.memo(React.forwardRef(watch(/* */)))
// equals to
watch.memo.forwardRef(/* */)
watch.forwardRef.memo(/* */)
useSweety
function useSweety<T>(
initialState: T | (() => T),
compare?: null | Compare<T>
): Sweety<T>
initialState
argument is the state used during the initial render. If the initial state is the result of an expensive computation, you may provide a function instead, which will be executed only on the initial render.[compare]
is an optionalCompare
function applied asSweety#compare
. When not defined ornull
thenObject.is
applies as a fallback.
A hook that initiates a stable (never changing) Sweety
instance.
💬 The initial state is disregarded during subsequent re-renders.
useWatchSweety
function useWatchSweety<T>(
watcher: () => T,
compare?: null | Compare<T>
): T
watcher
is a function that subscribes to allSweety
instances calling theSweety#getState
method inside the function.[compare]
is an optionalCompare
function. When not defined ornull
thenObject.is
applies as a fallback.
The useWatchSweety
hook is an alternative to the watch
function. It executes the watcher
function whenever any of the involved Sweety
instances' state update but enqueues a re-render only when the resulting value is different from the previous.
Custom hooks can use useWatchSweety
for reading and transforming the Sweety
instances' states, so the host component doesn't need to wrap around the watch
HOC:
const useSumAllAndMultiply = ({
multiplier,
counts,
}: {
multiplier: Sweety<number>
counts: Sweety<Array<Sweety<number>>>
}): number => {
return useWatchSweety(() => {
const sumAll = counts
.getState()
.map((count) => count.getState())
.reduce((acc, x) => acc + x, 0)
return multiplier.getState() * sumAll
})
}
Components can scope watched Sweety
instances to reduce re-rendering:
const Challenge: React.FC = () => {
const count = useSweety(0)
// the component re-renders only once when the `count` is greater than 5
const isMoreThanFive = useWatchSweety(() => count.getState() > 5)
return (
<div>
<Counter count={count} />
{isMoreThanFive && <p>You did it 🥳</p>}
</div>
)
}
💬 The
watcher
function is only for reading theSweety
instances' states. It should never callSweety.of
,Sweety#clone
,Sweety#setState
, orSweety#subscribe
methods inside.
💡 It is recommended to memoize the
watcher
function withReact.useCallback
for better performance.
💡 Keep in mind that the
watcher
function acts as a "reader" so you'd like to avoid heavy calculations inside it. Sometimes it might be a good idea to pass a watcher 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.
useSweetyMemo
function useSweetyMemo<T>(
factory: () => T,
dependencies: ReadonlyArray<unknown> | undefined,
): T
factory
is a function calculates a valueT
whenever any of thedependencies
' values change.dependencies
is an array of values used in thefactory
function.
The hook is a Sweety
version of the React.useMemo
hook. During the factory
execution, all the Sweety
instances that call the Sweety#getState
method become phantom dependencies of the hook.
The factory
runs again whenever any dependency or a state of any phantom dependency changes:
const useCalcSum = (left: number, right: Sweety<number>): number => {
// the factory runs whenever:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
// 3. `right.getState()` changes (`right` mutates)
return useSweetyMemo(() => {
return left + right.getState()
}, [left, right])
}
The phantom dependencies might be different per factory
call. If a Sweety
instance does not call the Sweety#getState
method, it does not become a phantom dependency:
const useCalcSum = (left: number, right: Sweety<number>): number => {
// the factory runs when either:
//
// `left` > 0:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
// 3. `right.getState()` changes (`right` mutates)
//
// OR
//
// `left` <= 0:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
return useSweetyEffect(() => {
if (left > 0) {
return left + right.getState()
}
return left
}, [left, right])
}
💡 Want to see ESLint suggestions for the dependencies? Add the hook name to the ESLint rule override:
{ "react-hooks/exhaustive-deps": [ "error", { "additionalHooks": "(useSweetyEffect|useSweetyLayoutEffect|useSweetyMemo)" } ] }
useSweetyEffect
function useSweetyEffect(
effect: () => (void | VoidFunction),
dependencies?: ReadonlyArray<unknown>,
): void
effect
is a function that 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 a Sweety
version of the React.useEffect
hook. During the effect
execution, all the Sweety
instances that call the Sweety#getState
method become phantom dependencies of the hook.
The effect
runs again whenever any dependency or a state of any phantom dependency changes:
const usePrintSum = (left: number, right: Sweety<number>): void => {
// the effect runs whenever:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
// 3. `right.getState()` changes (`right` mutates)
useSweetyEffect(() => {
console.log("sum is %d", left + right.getState())
}, [left, right])
}
The phantom dependencies might be different per effect
call. If a Sweety
instance does not call the Sweety#getState
method, it does not become a phantom dependency:
const usePrintSum = (left: number, right: Sweety<number>): void => {
// the effect runs when either:
//
// `left` > 0:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
// 3. `right.getState()` changes (`right` mutates)
//
// OR
//
// `left` <= 0:
// 1. `left` changes
// 2. `right` changes (new `Sweety` instance)
useSweetyEffect(() => {
if (left > 0) {
console.log("sum is %d", left + right.getState())
}
}, [left, right])
}
💡 Want to see ESLint suggestions for the dependencies? Add the hook name to the ESLint rule override:
{ "react-hooks/exhaustive-deps": [ "error", { "additionalHooks": "(useSweetyEffect|useSweetyLayoutEffect|useSweetyMemo)" } ] }
useSweetyLayoutEffect
The hook is a Sweety
version of the React.useLayoutEffect
hook. Acts similar way as useSweetyEffect
.
~~useSweetyInsertionEffect
~~
There is no Sweety
version of the React.useInsertionEffect
hook due to backward compatibility with React from v16.8.0
. The workaround is to use the native React.useInsertionEffect
hook with the states extracted beforehand:
const usePrintSum = (left: number, right: Sweety<number>): void => {
const rightState = useSweetyState(right)
React.useInsertionEffect(() => {
console.log("sum is %d", left + rightState)
}, [left, rightState])
}
useSweetyState
function useSweetyState<T>(sweety: Sweety<T>): T
A hook that subscribes to the sweety
changes and returns the current state.
sweety
is aSweety
instance.
const Input: React.FC<{
value: Sweety<string>
}> = ({ value }) => {
const text = useSweetyState(value)
return (
<input
type="text"
value={text}
onChange={(event) => value.setState(event.target.value)}
/>
)
}
batch
function batch(execute: VoidFunction): void
The batch
function is a helper to optimize multiple Sweety
updates.
execute
is a function that executes multipleSweety#setState
calls at ones.
const SumOfTwo: React.FC<{
left: Sweety<number>
right: Sweety<number>
}> = watch(({ left, right }) => (
<div>
<span>Sum is: {left.getState() + right.getState()}</span>
<button
onClick={() => {
// enqueues 1 re-render instead of 2 🎉
batch(() => {
left.setState(0)
right.setState(0)
})
}}
>
Reset
</button>
</div>
))
Compare
type Compare<T> = (left: T, right: T) => 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.
Publish
Here are scripts you want to run for publishing a new version to NPM:
npm version {version}
ex:npm version 1.0.0-beta.1
npm run build
npm publish --tag {tag}
ex:npm publish --tag beta --tag latest
git push
git push --tags