THE LIBRARY HAS CHANGED NPM NAME FROM react-inner-store TO react-sweety.
react-inner-store
The clean and natural React state management
Get started
First, install the package:
# with yarn
yarn add react-inner-store
# with npm
npm install react-inner-store
And use it in your project:
import React from "react"
import { InnerStore, useInnerState } from "react-inner-store"
type State = {
username: InnerStore<string>
count: InnerStore<number>
}
const Username: React.VFC<{
store: InnerStore<string>
}> = React.memo(({ store }) => {
const [username, setUsername] = useInnerState(store)
return (
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
)
})
const Counter: React.VFC<{
store: InnerStore<number>
}> = React.memo(({ store }) => {
const [count, setCount] = useInnerState(store)
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
})
const App: React.VFC<{
state: State
}> = React.memo(({ state }) => (
<div>
<Username store={state.username} />
<Counter store={state.count} />
<button
onClick={() => {
const username = state.username.getState()
const count = state.count.getState()
console.log(`User "${username}" gets ${count} score.`)
}}
>
Submit
</button>
</div>
))
ReactDOM.render(
<App
state={{
username: InnerStore.of(""),
count: InnerStore.of(0),
}}
/>,
document.getElementById("root"),
)
Motivation
Another one React state management library... Why do you need it? That's a fair question and it needs a decent explanation. Let me walk you through it.
Imagine you are building a stateful Counter component:
import React from "react"
const Counter: React.VFC = () => {
const [count, setCount] = React.useState(0)
return (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}
That's fairly simple but not quite useful since there is no way to read the Counter's value. You want to keep the state inside the component, so the only way to get the value is to pass the onChange callback to the Counter component:
import React from "react"
const Counter: React.VFC<{
onChange?(count: number): void
}> = React.memo(({ onChange }) => {
const [count, setCount] = React.useState(0)
const handleCount = (nextCount) => {
setCount(nextCount)
onChange?.(nextCount)
}
return (
<div>
<button onClick={() => handleCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => handleCount(count + 1)}>+</button>
</div>
)
})
Now you can get the value from the Counter's parent component, but you need a place to store it:
import React from "react"
const GameScore = () => {
const [count, setCount] = React.useState(0)
return (
<div>
<Counter onChange={setCount} />
<span>Score: {count}</span>
</div>
)
}
Two React.useState for storing a single value... seems a bit of overkill, huh? Let's move on and say that it should be a way not only to read but to set the Counter's value from the outside:
import React from "react"
const Counter: React.VFC<{
count?: number
onChange?(count: string): void
}> = ({ count: forcedCount = 0, onChange }) => {
const [count, setCount] = React.useState(forcedCount)
const handleCount = (nextCount) => {
setCount(nextCount)
onChange?.(nextCount)
}
React.useEffect(() => {
setCount(forcedCount)
}, [forcedCount])
return (
<div>
<button onClick={() => handleCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => handleCount(count + 1)}>+</button>
</div>
)
}
const GameScore = () => {
const [count, setCount] = React.useState(0)
return (
<div>
<Counter count={count} onChange={setCount} />
<button onClick={() => setCount(0)}>Reset</button>
<span>Score: {count}</span>
</div>
)
}
That is a complete implementation of two-way Counter's state management. The number of hooks to support the two-way binding grows dramatically with a more complex state.
A brute-force workaround to reduce the two-way binding hustle is to store the Input's state outside the component. This way, any component which passes the state and the setState callback might read and change the state's value:
import React from "react"
const Counter: React.VFC<{
count: number
setCount: React.Dispatch<React.SetStateAction<number>>
}> = ({ count, setCount }) => (
<div>
<button onClick={() => setCount(count - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
// The game score shows two Counters now
const GameScore = () => {
const [firstCount, setFirstCount] = React.useState(0)
const [secondCount, setSecondCount] = React.useState(0)
return (
<div>
<Counter count={firstCount} setCount={setFirstCount} />
<Counter count={secondCount} setCount={setSecondCount} />
<button
onClick={() => {
setFirstCount(0)
setSecondCount(0)
}}
>
Reset
</button>
<span>
Score: {firstCount} vs {secondCount}
</span>
</div>
)
}
So far, so good, is not it? The problem is that the approach does not scale well. What if it needs to read and write the GameStore's state from the outside:
const GameScore: React.VFC<{
firstCount: number
secondCount: number
setFirstCount: React.Dispatch<React.SetStateAction<number>>
setSecondCount: React.Dispatch<React.SetStateAction<number>>
}> = ({ firstCount, setFirstCount, secondCount, setSecondCount }) => (
<div>
<Counter count={firstCount} setCount={setFirstCount} />
<Counter count={secondCount} setCount={setSecondCount} />
<button
onClick={() => {
setFirstCount(0)
setSecondCount(0)
}}
>
Reset
</button>
<span>
Score: {firstCount} vs {secondCount}
</span>
</div>
)
That's props drilling - it grows exponentially and requires too much effort to maintain. We have to figure out how to stop the props amount from growing. We can switch from React.useState to React.useReducer and have a single state prop and a single dispatch prop. Assuming so, here is how the Counter looks like now:
type CounterId = string
interface CounterState {
id: CounterId
count: number
}
const initCounter = (): CounterState => ({
id: uuid(),
count: 0,
})
type CounterAction =
| { type: "INCREMENT"; id: CounterId }
| { type: "DECREMENT"; id: CounterId }
const counterReducer = (state: CounterState, action: CounterAction) => {
switch (action.type) {
case "INCREMENT":
return state.id === action.id
? { ...state, count: state.count + 1 }
: state
case "DECREMENT":
return state.id === action.id
? { ...state, count: state.count - 1 }
: state
default:
return state
}
}
const Counter: React.VFC<{
state: CounterState
dispatch: React.Dispatch<CounterAction>
}> = ({ state, dispatch }) => (
<div>
<button onClick={() => dispatch({ type: "DECREMENT", id: state.id })}>
-
</button>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "INCREMENT", id: state.id })}>
+
</button>
</div>
)
We exchanged props drilling to boilerplate code. But why does it need the extra id field in both state and actions? The answer is that we want to have reusable components, and the Counter component will be used many times across the application. It might be a different component, rather than Counter with very complex state management. When we have done with the Counter, let's convert GameScore in the same manner:
type GameScoreId = string
interface GameScoreState {
id: GameScoreId
firstCounter: CounterState
secondCounter: CounterState
}
const initGameScore = (): GameScoreState => ({
id: uuid(),
firstCounter: initCounter(),
secondCounter: initCounter(),
})
const resetGameScore = (state: GameScoreState): GameScoreState => ({
...state,
firstCounter: { ...state.firstCounter, count: 0 },
secondCounter: { ...state.secondCounter, count: 0 },
})
type GameScoreAction = { type: "RESET"; id: GameScoreId }
const gameScoreReducer = (state: GameScoreState, action: GameScoreAction) => {
switch (action.type) {
case "RESET":
return resetGameScore(state)
default:
return {
...state,
firstCounter: counterReducer(state.firstCounter, action),
secondCounter: counterReducer(state.secondCounter, action),
}
}
}
const GameScore: React.VFC<{
state: GameScoreState
dispatch: React.Dispatch<GameScoreAction>
}> = ({ state, dispatch }) => (
<div>
<Counter state={state.firstCounter} dispatch={dispatch} />
<Counter state={state.secondCounter} dispatch={dispatch} />
<button onClick={() => dispatch({ type: "RESET", id: state.id })}>
Reset
</button>
<span>
Score: {state.firstCounter.count} vs {state.secondCounter.count}
</span>
</div>
)
Quite some boilerplate code again. But let's move on and finally make the App component:
interface AppState {
games: ReadonlyArray<GameScoreState>
}
const prepareAppRequestPayload = (state: AppState) => ({
games: state.games.map((game) => ({
firstCounter: game.firstCounter.count,
secondCounter: game.secondCounter.count,
})),
})
type AppAction = { type: "ADD_GAME" } | { type: "RESET_ALL_GAMES" }
const appReducer = (state: AppState, action: AppAction) => {
switch (action.type) {
case "ADD_GAME":
return {
...state,
games: [...state.games, initGameScore()],
}
case "RESET_ALL_GAMES":
return {
...state,
games: state.games.map(resetGameScore),
}
default:
return {
...state,
games: state.games.map((game) => gameScoreReducer(game, action)),
}
}
}
const App = () => {
const [state, dispatch] = React.useReducer(appReducer, {
games: [],
})
return (
<div>
<button onClick={() => dispatch({ type: "ADD_GAME" })}>Add game</button>
<button onClick={() => dispatch({ type: "RESET_ALL_GAMES" })}>
Reset all
</button>
<button onClick={() => sendGames(prepareAppRequestPayload(state))}>
Submit games
</button>
{state.games.map((game) => (
<GameScore key={game.id} state={game} dispatch={dispatch} />
))}
</div>
)
}
From now and on, any Counter increment will cause the entire App to reconcile. It might be limited by applying a bunch of React optimization techniques and extra checks in reducers, but this is extra work and extra lines of code. You might also notice that any Counter's action dispatched will cause all Counter's reducers to handle the Counter's states instances.
But the problems above are relatively small compared to the amount of boilerplate and effort required to develop an app in that way. It would not be a case if we'd deal with only local state components, but the App needs access to read and write deeply nested values, so we have no choice but to define the state on App's level.
That is where react-inner-store comes to the rescue. It allows working with a propagated state in the same way as with a local state. Let's transform Counter to use react-inner-store:
react-inner-store
| classic React |
|---|---|
|
|
It looks like the first Counter implementation with React.useState only, doesn't it? A key difference is that any component with access to the store might read or write the state the same as Counter does!
Wanna see how the rest of the app code looks like? Click here!
react-inner-store
| classic React |
|---|---|
|
|
|
|
With react-inner-store we can now implement the same functionality without any boilerplate code but keep control over the app state. Moreover, any Counter's "action" will cause reconciliations only for its GameScore parent since no other components read the affected Counter's state.
API
A core concept of the library is the InnerStore class. It is a mutable wrapper around an immutable value that allows to prevent unnecessary re-renders. The class provides an API to get and set the value, and to observe changes. There are hooks built on top of the API for convenient usage in React components.
InnerStore.of
InnerStore.of<T>(value: T, compare?: null | Compare<T>): InnerStore<T>
A static method that creates a new InnerStore instance. The instance is mutable so once created it should be used for all future operations.
valueis the initial immutable value of the store.[compare]is an optionalComparefunction to set asInnerStore#compare. If thecomparefunction is not defined ornullthe strict equality check function (===) will be used.
type SignInFormState = {
isSubmitting: boolean
username: InnerStore<string>
password: InnerStore<string>
rememberMe: InnerStore<boolean>
}
const signInFormStore = InnerStore.of<SignInFormState>({
isSubmitting: false,
username: InnerStore.of(""),
password: InnerStore.of(""),
rememberMe: InnerStore.of(false),
})
InnerStore#key
InnerStore<T>#key: string
Each InnerStore instance has a unique key. This key is used internally for useInnerWatch but can be used as the React key property.
const Toggles: React.VFC<{
options: Array<InnerStore<boolean>>
}> = ({ options }) => (
<>
{options.map((option) => (
<Toggle key={option.key} store={option} />
))}
</>
)
InnerStore#compare
InnerStore<T>#compare: Compare<T>
The compare function compares the value of the store with the new value given via InnerStore#setState. If the function returns true the store will not be updated so no listeners subscribed via InnerStore#subscribe will be notified.
The
InnerStore#comparefunction has the lowest priority whenInnerStore#setState,useInnerState,useSetInnerStateoruseInnerReducerare called.
InnerStore#clone
InnerStore<T>#clone(
transform?: (value: T) => T,
compare?: null | Compare<T>
): InnerStore<T>
An InnerStore instance's method that creates a new InnerStore instance with the same value.
[transform]is an optional function that will be applied to the current value before cloning. It might be handy when cloning aInnerStoreinstance that contains mutable values (e.g.InnerStore).[compare]an optionalComparefunction to replaceInnerStore#compareof the cloned instance. If not defined theInnerStore#comparefunction of the source instance will be used. Ifnullis passed the strict equality check function (===) will be used.
const signInFormStoreClone = signInFormStore.clone(
({ isSubmitting, username, password, rememberMe }) => ({
isSubmitting,
username: username.clone(),
password: password.clone(),
rememberMe: rememberMe.clone(),
}),
)
InnerStore#getState
InnerStore<T>#getState(): T
InnerStore<T>#getState<R>(transform: (value: T) => R): R
An InnerStore instance's method that returns the current value.
[transform]is an optional function that will be applied to the current value before returning.
const plainSignInState = signInFormStore.getState(
({ isSubmitting, username, password, rememberMe }) => ({
isSubmitting,
username: username.getState(),
password: password.getState(),
rememberMe: rememberMe.getState(),
}),
)
InnerStore#setState
InnerStore<T>#setState(
valueOrTransform: React.SetStateAction<T>,
compare?: null | Compare<T>
): void
An InnerStore instance's method that sets the value. Each time when the value changes all of the store's listeners passed via InnerStore#subscribe are called.
valueOrTransformis the new value or a function that transforms the current value into the new value.[compare]is an optionalComparefunction to use for this call only. If not defined theInnerStore#comparefunction of the instance will be used. Ifnullis passed the strict equality check function (===) will be used.
const onSubmit = () => {
signInFormStore.update((state) => {
// reset password field
state.password.setState("")
return {
...state,
isSubmitting: true,
}
})
}
If
valueOrTransformargument is a function it acts asbatch.
The method returns
voidto emphasize thatInnerStoreinstances are mutable.
The second argument
comparefunction has medium priority, so it will be used instead ofInnerStore#compare.
InnerStore#subscribe
InnerStore<T>#subscribe(listener: VoidFunction): VoidFunction
An InnerStore instance's method that subscribes to the store's value changes caused by InnerStore#setState calls. Returns a cleanup function that unsubscribes the listener.
listeneris a function that a store will call when the value changes.
const UsernameInput: React.VFC<{
store: InnerStore<string>
}> = React.memo(({ store }) => {
const [username, setUsername] = React.useState(store.getState())
React.useEffect(() => {
// the listener is called on every store.setState() call across the app
return store.subscribe(() => setUsername(store.getState()))
}, [store])
return (
<input
type="text"
value={username}
// all store.subscribe across the app will call their listeners
onChange={(event) => store.setState(event.target.value)}
/>
)
})
The example above is for demonstration purposes only. In real world app it's usually better use provided hooks in most cases.
useInnerWatch
function useInnerWatch<T>(watcher: () => T, compare?: null | Compare<T>): T
A hook that subscribes to all InnerStore#getState execution involved in the watcher call. Due to the mutable nature of InnerStore instances a parent component won't be re-rendered when a child's InnerStore value is changed. The hook gives a way to watch after deep changes in the store's values and trigger a re-render when the returning value is changed.
watcheris a function to read only the watching value meaning that it should never callInnerStore.of,InnerStore#clone,InnerStore#setStateorInnerStore#subscribemethods inside.[compare]is an optionalComparefunction with strict check (===) by default or whennull. The hook won't trigger a re-render when the watching value is comparably equal to the current value.
type State = {
count: InnerStore<number>
}
const App: React.VFC<{
state: State
}> = React.memo(({ state }) => {
// the component will re-render once the `count` is greater than 5
// and once the `count` is less or equal to 5
const isMoreThanFive = useInnerWatch(() => state.count.getState() > 5)
return (
<div>
<Counter store={state.count} />
{isMoreThanFive && <p>You did it!</p>}
</div>
)
})
It is recommended to memoize the
watcherfunction for better performance.
Keep in mind that the
watcherfunction 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 thecomparefunction - you should choose wisely between avoiding extra re-renders and heavy comparisons.
useInnerState
function useInnerState<T>(
store: InnerStore<T>,
compare?: null | Compare<T>,
): [T, SetInnerState<T>]
function useInnerState<T>(
store: null | undefined | InnerStore<T>,
compare?: null | Compare<T>,
): [null | undefined | T, SetInnerState<T>]
A hook that is similar to React.useState but for InnerStore instances. It subscribes to the store changes and returns the current value and a function to set the value.
storeis anInnerStoreinstance but can benullorundefinedas a bypass when there is no need to subscribe to the store's changes.[compare]is an optionalComparefunction. The store won't update if the new value is comparably equal to the current value. If not defined it usesInnerStore#compare. The strict equality check function (===) will be used ifnull.
const UsernameInput: React.VFC<{
store: InnerStore<string>
}> = React.memo(({ store }) => {
const [username, setUsername] = useInnerState(store)
return (
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
)
})
The hook is a combination of
useGetInnerStateanduseSetInnerState, so use them if you need to either get+subscribe or set the store's value.
The second argument
comparefunction has medium priority, so it will be used instead ofInnerStore#compare.
useGetInnerState
function useGetInnerState<T>(store: InnerStore<T>): T
function useGetInnerState<T>(
store: null | undefined | InnerStore<T>,
): null | undefined | T
A hooks that subscribes to the store's changes and returns the current value.
storeis anInnerStoreinstance but can benullorundefinedas a bypass when there is no need to subscribe to the store's changes.
const App: React.VFC<{
left: InnerStore<number>
right: InnerStore<number>
}> = React.memo(({ left, right }) => {
const countLeft = useGetInnerState(left)
const countRight = useGetInnerState(right)
return (
<div>
<Counter store={left} />
<Counter store={right} />
<p>Sum: {countLeft + countRight}</p>
</div>
)
})
useSetInnerState
function useSetInnerState<T>(
store: null | undefined | InnerStore<T>,
compare?: null | Compare<T>,
): SetInnerState<T>
A hooks that returns a function to update the store's value. Might be useful when you need a way to update the store's value without subscribing to its changes.
storeis anInnerStoreinstance but can benullorundefinedas a bypass when a store might be not defined.[compare]is an optionalComparefunction. The store won't update if the new value is comparably equal to the current value. If not defined it usesInnerStore#compare. The strict equality check function (===) will be used ifnull.
type State = {
count: InnerStore<number>
}
const App: React.VFC<{
state: State
}> = React.memo(({ state }) => {
// the component won't re-render on the count value change
const setCount = useSetInnerState(state.count)
return (
<div>
<Counter store={state.count} />
<button onClick={() => setCount(0)}>Reset count</button>
</div>
)
})
The second argument
comparefunction has medium priority, so it will be used instead ofInnerStore#compare.
useInnerReducer
function useInnerReducer<A, T>(
store: InnerStore<T>,
reducer: (state: T, action: A) => T,
compare?: null | Compare<T>,
): [T, React.Dispatch<A>]
function useInnerReducer<A, T>(
store: null | undefined | InnerStore<T>,
reducer: (state: T, action: A) => T,
compare?: null | Compare<T>,
): [null | undefined | T, React.Dispatch<A>]
A hook that is similar to React.useReducer but for InnerStore instances. It subscribes to the store changes and returns the current value and a function to dispatch an action.
storeis anInnerStoreinstance but can benullorundefinedas a bypass when there is no need to subscribe to the store's changes.reduceris a function that transforms the current value and the dispatched action into the new value.[compare]is an optionalComparefunction. The store won't update if the new value is comparably equal to the current value. If not defined it usesInnerStore#compare. The strict equality check function (===) will be used ifnull.
type CounterAction = { type: "INCREMENT" } | { type: "DECREMENT" }
const counterReducer = (state: number, action: CounterAction) => {
switch (action.type) {
case "INCREMENT":
return state + 1
case "DECREMENT":
return state - 1
}
}
const Counter: React.VFC<{
store: InnerStore<number>
}> = React.memo(({ store }) => {
const [count, dispatch] = useInnerReducer(store, counterReducer)
return (
<div>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-</button>
<span>{count}</span>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
</div>
)
})
The third argument
comparefunction has medium priority, so it will be used instead ofInnerStore#compare.
batch
The batch function is a helper to optimise multiple stores' updates.
const LoginForm: React.VFC<{
email: InnerStore<string>
password: InnerStore<string>
}> = ({ email: emailStore, password: passwordStore }) => {
const [email, setEmail] = useInnerState(emailStore)
const [password, setPassword] = useInnerState(passwordStore)
return (
<form>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button
type="button"
onClick={() => {
api.login(email, password)
batch(() => {
setEmail("")
setPassword("")
})
}}
>
Submit
</button>
</form>
)
}
Compare
type Compare<T> = (prev: T, next: 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.
SetInnerState
type SetInnerState<T> = (
valueOrTransform: React.SetStateAction<T>,
compare?: null | Compare<T>,
) => void
A function that similar to the React.useState callback but with extra compare function.
valueOrTransformis the new value or a function that transforms the current value into the new value.[compare]is an optionalComparefunction to use for this call only. If not defined thecomparefunction of the source hook will be used. Ifnullis passed the strict equality check function (===) will be used.
If
valueOrTransformargument is a function it acts asbatch.
The second argument
comparefunction has the highest priority so it will be used instead ofInnerStore#compareand any othercomparepassed viaInnerStore#setState,useInnerState,useSetInnerStateoruseInnerReducer.
ExtractInnerState
A helper type that shallowly extracts value type from InnerStore:
type SimpleStore = InnerStore<number>
// ExtractInnerState<SimpleStore> === number
type ArrayStore = InnerStore<Array<string>>
// ExtractInnerState<ArrayStore> === Array<string>
type ShapeStore = InnerStore<{
name: string
age: number
}>
// ExtractInnerState<ShapeStore> === {
// name: string
// age: number
// }
type ShapeOfStores = InnerStore<{
name: InnerStore<string>
age: InnerStore<number>
}>
// ExtractInnerState<ShapeStore> === {
// name: InnerStore<string>
// age: InnerStore<number>
// }
DeepExtractInnerState
A helper that deeply extracts value type from InnerStore:
type ShapeOfStores = InnerStore<{
name: InnerStore<string>
age: InnerStore<number>
}>
// DeepExtractInnerState<ShapeStore> === {
// name: string
// age: number
// }
type ArrayOfStores = InnerStore<Array<InnerStore<boolean>>>
// DeepExtractInnerState<ArrayOfStores> === Array<boolean>
Dispatch
Re-export of React.Dispatch.
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.1npm run buildnpm publish --tag {tag}ex:npm publish --tag beta --tag latestgit pushgit push --tags