README
React-use-state-x
Tiny, type-safe, feature-rich useState-like React hook to manage complex state (objects, arrays, nested data, forms) and global stores (alternative to Redux/Mobx)
- Quick start - tiny demos in very short code samples
- Features - why react-use-state-x?
API guide table of contents:
- Array state -
useState
for arrays - Object state -
useState
for objects - Complex state -
useState
for complex data- Form state - two-way data binding, valuelink pattern
- Input validation - automated validation of complex data in easy way
- Modification detection - automated detection if the current state and initial state are different
- Preset hook - reject or alter state mutations
- Cached state - optimise updates of deeply nested complex state data
- Global state -
useState
for complex data stored globally- Global state reducer - build your custom global type-safe stores and reduce actions, replace Redux or Mobx
Quick start
Array state - useState
for arrays (read more...):
import { useStateArray } from 'react-use-state-x';
const UseStateArrayExample = () => {
// there are other actions available in addition to push and remove
const [array, { push, remove }] = useStateArray([1, 2]);
return (
<div>
{array.map((elem, index) =>
<span>
Element {elem} (<button onClick={() => remove(index)}>Remove</button>)
</span>
)}
<button onClick={() => push(array.length)}>Add</button>
</div>
);
};
Object state - useState
for objects (read more...):
import { useStateObject } from 'react-use-state-x';
const UseStateObjectExample = () => {
// there are other actions available in addition to merge and remove
const [instance, { merge, update }] = useStateObject({ a: 1, b: 'two', c: false });
return (
<div>
Current object state: {JSON.stringify(instance)}
<button onClick={() => update('a', 2)}>Update A field</button>
<button onClick={() => merge({ b: 'Three', c: true })}>Update B and C fields</button>
</div>
);
};
Complex state and Form state - useState
for complex data and two-way form data binding via valuelink pattern (read more...):
import { useStateLink } from 'react-use-state-x';
const UseStateLinkExample = () => {
const stateLink = useStateLink({
title: 'Code Complete',
popularity: 1
}, {
targetHooks: {
popularity: {
__validate: (v) => !Number.isFinite(v) ? 'Popularity should be a number' : undefined
}
}
});
const links = stateLink.nested; // obtain valuelinks to nested fields
return (
<div>
<label>Title</label>
<input value={links.title.value} onChange={e => links.title.set(e.target.value)} />
<label>Popularity</label>
<input value={links.popularity.value} onChange={e => links.popularity.set(Number(e.target.value))} />
{stateLink.valid ? 'The input is valid' : stateLink.errors.join(',')}
</div>
);
};
Global state and Global state reducer - useState
for complex data stored globally wrapped by strict type-safe action reducer API (alternative to Redux/Mobx, read more...):
//
// file: Store.tsx - implementation of type-safe, strict API for global store
//
import { useStateLink, createStateLink } from 'react-use-state-link';
export interface Book {
title: string;
popularity: number;
}
const Store = createStateLink<Book[]>([{
title: 'Code complete',
popularity: 1,
}]);
export const StoreObserver = Store.Observer;
export function useStore() {
const link = useStateLink(Store);
return {
addBook(book: Book) {
link.inferred.push(book);
},
updateTitle(bookIndex: number, title: string) {
link.nested[bookIndex].nested.title.set(title);
},
getBooks(): Book[] {
return link.value;
}
};
}
//
// file: OtherComponent.tsx - demonstrates how to use the global store in a component
//
import { useStore } from './Store'
const UseGlobalStoreExample = () => {
const store = useStore();
return (
<div>
{store.getBooks().map((book, index) =>
// show the title in the editable input box for each book
<input key={index} value={book.title} onChange={e => store.updateTitle(index, e.target.value)} />
)}
<button onClick={() => store.addBook({ title: 'Code complete', popularity: 1 })} >
Add another book
</button>
</div>
);
};
//
// file: App.tsx - activates the usage of the global store for the entire app
//
import { StoreObserver } from './Store';
const App = () => {
return (
<StoreObserver>
{
// nested components, which use the Store will re-render
// when the Store state is changed
}
<UseGlobalStoreExample />
</StoreObserver>
);
};
Features
- Concise, pragmatic but flexible API
- very easy to learn
- no boilerplate, just plain predictable state management
- First-class typescript support
- completely written in typescript
- compiles to javascript module and typescript definitions
- correct and complete type inferrence for any type / complexity of managed data
- Tiny footprint. No external dependencies, except React.
- State management for complex data state, including:
- arrays and objects
- deeply nested combinations on arrays and objects
- validation of input data
- tracking of modifications
- valuelink-like pattern for two-way data binding and form state management
- Global data state management using the same API
- allows to drop Mobx / Redux completely and simplify the source code a lot
- Performance tuned:
- offers component-level cache state management to minimise re-rendering when necessary
- efficient global state observer using only
React.useContext
andReact.useState
Contribution
Use github ticketing system to ask questions, report bugs or request features. Feel free to submit pull requests.
Installation
Using NPM:
npm install --save react-use-state-x
Using yarn:
yarn add react-use-state-x
API guide
Array state
useStateArray
returns the current state of an array instance and a set of functions to mutate the state of the array in various ways. The following example demonstrates the usage of push
mutation action, which adds one more element in to the array.
const UseStateArrayExample = () => {
const [array, { push }] = useStateArray([1, 2]);
return (
<div>
{array.join(',')}
<button onClick={() => push(array.length)}>Add</button>
</div>
);
};
There the following array mutation actions available:
set([...])
orset((prevState) => [...])
sets new value of the array state. It has got the same behaviour as the second value returned from theReact.useState
functionmerge({...})
ormerge((prevState) => ({...}))
sets new value of the array state, updating the provided elements of the array, for example:merge({ 0: 'the first element is updated', 4: 'and the fifth too', })
Note:
prevState
variable in the callback is a clone/copy of the current array stateupdate(index, newElementValue)
orupdate(index, (prevElementValue) => newElementValue)
sets new value of the array state, updating the element of an array by the specified indexconcat([...])
orconcat((prevState) => [...])
sets new value of the array state, appending the provided array to the end of the current array.Note:
prevState
variable in the callback is a clone/copy of the current array statepush(newElement)
sets new value of the array state, adding new element to the endpop()
sets new value of the array state, removing the last elementinsert(indexWhereToInsert, newElement)
sets new value of the array state, inserting the new element by the specified indexremove(index)
sets new value of the array state, removing the element by the specified indexswap(index1, index2)
sets new value of the array state, swapping two elements by the specified indexes
Object state
useStateObject
returns the current state of an object instance and a set of functions to mutate the state of the object in various ways. The following example demonstrates the usage of merge
mutation action, which updates the specified properties of the object.
const UseStateObjectExample = () => {
const [instance, { merge }] = useStateObject({ a: 1, b: 'two' });
return (
<div>
{JSON.stringify(instance)}
<button onClick={() => merge({ b: 'Three' })}>Modify instance</button>
</div>
);
};
There the following object mutation actions available:
set([...])
orset((prevState) => [...])
sets new value of the object state. It has got the same behaviour as the second value returned from theReact.useState
functionmerge({...})
ormerge((prevState) => ({...}))
sets new value of the object state, updating the specified propertiesupdate(propertyKey, newPropertyValue)
orupdate(propertyKey, (prevPropertyValue) => newPropertyValue)
sets new value of the object state, updating the specified property
Complex state
When the state data contains a mix of nested objects, arrays and primitive variables of different types, useStateArray
or useStateObject
are not sufficient anymore. We need something what can provide set-state-like actions for nested data and something what is aware of the types of these nested objects and arrays.
useStateLink
is used in this case. For example:
interface Book {
title: string;
popularity: number;
authors: string[];
}
interface Catalog {
books: Book[];
lastUpdated: Date;
}
const UseStateLinkExample = () => {
// type annotation is for documentation purposes,
// it is inferred by the compiler automatically
const link: ValueLink<Catalog> = useStateLink({
books: [
{
title: 'Code Complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
} as Catalog);
...
};
The link
variable is of type ValueLink<Catalog>
, which has got two fundamental properties:
value
- returns the instance of data, ofCatalog
type in this example- and
set(...)
orset((prevState) => ...)
- function which allows to set new value state, ofCatalog
type in this example, similarly to the setState variable returned byReact.useState
hook.
The set
function will not accept partial updates. Object state mutation actions, like merge
, update
, etc. are available via inferred
property. For example:
link.inferred.update('lastUpdated', new Date());
Because it is all typescript compiler checked, the first key for update
can be only the names of the properties of the type of value
, Catalog
type in our example.
Updating the nested property, will cause the update of the original state, representated by link.value
instance.
However, there is better way to update the nested fields:
link.nested.lastUpdated.set(new Date());
nested
'converts' a valuelink of an object to an object of nested value links. link.nested
object will contain the same keys as the value
object. These properties will be of type ValueLink<T>
- nested value links to manage the state of nested fields. In the example above, we set the value of ValueLink<Date>
link. Because it is all typescript compiler checked, it can only be a value of type Date
.
Similarly, we can obtain the link to the books
array:
// type annotation is for documentation purposes,
// it is inferred by the compiler automatically
const booksLink: ValueLink<Book[]> = link.nested.books;
It is nested valuelink of an array value. It has got value
and set
properties. Array mutation actions, like push
, pop
, insert
, etc. can be accessed via inferred
property. For example:
booksLink.inferred.push({
title: 'Rapid Development',
popularity: 2,
authors: ['Steve McConnell']
});
Again, because it is typescript compiler checked, push
will require the argument of compatible type, Book
in the example.
Similary to object valuelink, array valuelink has got nested
property, which 'converts' value link of an array to an array of nested valuelinks. For example:
// type annotation is for documentation purposes,
// it is inferred by the compiler automatically
const firstBookLink: ValueLink<Book> = booksLink.nested[0];
firstBookLink
is valuelink of an object. Similarly to updating the property on the root object, we can update the property on the nested object:
firstBookLink.nested.popularity.set(prevValue => prevValue + 1);
ValueLink
's path
property captures 'Javascript' object 'path' to an element relative to the root object. For example:
link.path === []
link.nested.books.path === ["books"]
link.nested.books.nested[0].path === ["books", 0]
link.nested.books.nested[0].nested.title.path === ["books", 0, "title"]
I hope, this example demonstrates how it is possible to traverse the complex data and manage deeply nested properties.
Form state
Form state is frequently referred as two-way data binding. Valuelink pattern is perfect way to achieve it. Because useStateLink
returns ValueLink
it can be used straight away to manage form state. Let's continue the above example:
const BookEditorExample = (props: { book: Book }) => {
// note we obtain nested links straight away
const links = useStateLink(props.book).nested;
return (
<div>
<label>Title</label>
<input
value={links.title.value}
onChange={e => links.title.set(e.target.value)}
/>
<label>Popularity</label>
<input
value={links.popularity.value}
onChange={e => links.popularity.set(Number(e.target.value))}
/>
</div>
);
};
The nested link to edit a book, could even come from the parent component, i.e. all down from the top component, which uses useStateLink
hook.
const BookEditorExample = (props: { link: ValueLink<Book> }) => {
const links = link.nested;
...
};
Input validation
Since we integrated the valuelink with the form state and user input, it would be good to have some data validation logic put in place.
useStateLink
allows to define data validation rules. The rules can be attached to entire root level objects or deeply nested fields. The structure of validation rules is type checked by typescript compiler, i.e. it will not allow rules for unknown properties, for example.
Let's expand the above example, adding some validation logic:
const link = useStateLink<Catalog>({
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
}, {
targetHooks: {
books: {
__validate: v => v.length === 0 ? 'There should be at least one book' : undefined,
// wild-card hooks, applied to all elements of an array,
// which do not have specificaly targeted hooks
'*': {
title: {
__validate: v => v.length === 0 ? 'There should be non-empty book title' : undefined
},
popularity: {
__validate: v => !Number.isFinite(v) ? 'Popularity should be a number' : undefined
}
},
// hooks specifically targeting the first element of an array
0: { }
}
}
});
We provide the seconds argument of type Settings<Catalog>
, which contains some __validation
hooks. Validation hooks should return error message (or array of error messages, i.e. string[]) in case of validation failure. And undefined
otherwise. These hooks are invoked by valuelink (or it's nested children valuelinks) when one of the following ValueLink
's properties is accessed:
errors
returns the array of all errors captured by validation. For example:const errorMessage = link.errors.firstPartial().message || 'Form data is valid';
valid
(invalid
) returns true (false) if there are noerrors
. It can be used to prevent form submit action:const submitButton = <button disabled={link.invalid}>Submit</button>;
Modification detection
ValueLink
properties modified
and unmodified
allow to check if the current value state is different to the initial state. It can be used to detect what parts of data have been touched, eg. in form input. For example:
// true if any of nested fields have been modified
link.modified;
// true if any of books have been modified
link.nested.books.modified;
// true if any property of the first book has been modified
link.nested.books.nested[0].modified;
// true if title of the first book has been modified
link.nested.books.nested[0].nested.title.modified;
The initialValue
property of ValueLink
returns the initial state value.
The default comparison operator checks for structural identity. It is effectively the same as JSON.stringify(link.initialValue) === JSON.stringify(link.value)
. The comparison operator can be modified or extended using valuelink settings, similarly to validation settings. For example:
const link = useStateLink<Catalog>({
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
}, {
targetHooks: {
__compare: (current, initial, l) => current.lastUpdated === (initial && initial.lastUpdated),
}
});
If __compare
hook returns undefined
, default comparison operator is used.
Preset hook
In addition to __validate
and __compare
hooks, there is __preset
hook. It can be used to reject invalid input, alter the mutation actions or for debugging purposes.
For example, let's say our Book
's popularity
input field should allow only digits:
const BookPopularityEditorExample = () => {
const valuelink = useStateLink(0, {
targetHooks: {
// the hook returns new value if number,
// and the old value, otherwise
// so, typing characters in the input field below
// makes no effect
__preset: (v: number, l: ReadonlyValueLink<number>) => Number.isFinite(v) ? v : l.value
}
});
return (
<div>
<label>Popularity</label>
<input
value={valuelink.value}
// Number() return NaN in case of bad input
onChange={e => valuelink.set(Number(e.target.value))}
/>
</div>
);
};
Let's say we also would like to update lastUpdated
property automatically every time any change is made to our catalog:
const link = useStateLink<Catalog>({
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
}, {
targetHooks: {
__preset: v => ({ lastUpdated: new Date(), ...v })
}
});
Preset hook is useful for debugging sometimes:
const link = useStateLink<Catalog>({
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
}, {
targetHooks: {
__preset: v => {
console.log('Catalog is updated', v)
return v;
},
books: {
__preset: v => {
console.log('Books are updated', v)
return v;
}
}
}
});
If we would like to put preset hook for every property, we can use globalHooks
instead of targetHooks
:
const link = useStateLink<Catalog>({
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
}, {
globalHooks: {
__preset: (v, l) => {
console.log(`Value by path ${l.path} is set to ${v}`);
return v;
}
},
});
Cached state
One of the problems with massive objects participating in React state lifecycle is performance of rendering of all of the components relying on the state data. This problem is not specific to this library, it is true in general: frequent massive data updates are hard in performance.
Returning to the Catalog
example above, an application could render views / edit-forms for all books from the single complex state of the Catalog
. It could use form state per every book to allow editing capabilities for the catalog. If a user decides to update a title of a single book, it would cause re-rendering for entire catalog on every key stroke, because update of nested data results in the update of the root state. The solution to this problem is to use temporary local to form component state, while capturing user's input, and update parent's state once editing is completed. For example:
const BookEditorExample = (props: { link: ValueLink<Book> }) => {
// we create local per-component state from the property supplied by the parent
const link = useStateLink(props.link.value);
return (
// when user leaves the editor, update the parent automatically
<div onBlur={() => props.link.set(link.value)}>
<label>Title</label>
<input
// use local state to manage user's input
value={link.nested.title.value}
onChange={e => link.nested.title.set(e.target.value)}
/>
<label>Popularity</label>
<input
// use local state to manage user's input
value={link.nested.popularity.value}
onChange={e => link.nested.popularity.set(Number(e.target.value))}
/>
<button
disabled={props.link.modifed}
// update parent's state once finished
onClick={() => props.link.set(link.value)}
>
Save
</button>
</div>
);
};
This improves the performance a lot, but does not allow us to reuse validation rules defined for the parent's valuelink. Let's modify the example and inherit validation rules and other hooks from the parent's link:
const BookEditorExample = (props: { link: ValueLink<Book> }) => {
// notice we dropped .value from props.link.value as an input for the initial local state
const link = useStateLink(props.link);
// the rest is the same
...
};
The created local link
inherited validation rules from the parent link.
Plus, there are two more bonuses:
- the parent link's value is updated from the local link's value automatically every time, when
valid
andmodified
status becomes different between parent link and local link. This allows parent components to react onvalid
andmodified
status changes as soon as they happen (i.e. while user types in the form.) - if the parent's state is updated by some other external way (eg. new data is received from a server), the local form state will use the updated parent's state instead of the last form's state
Global state
Previously we relied on React to store the state per component. When multiple components need to use the shared state, which can be initialized prior to mounting of React components, we need to create global state store. Traditionally it was done using libraries like Redux or Mobx. This library allow to achieve the same but with simpler API and cleaner code.
Let's create the global store (we continue using the same example as above):
const GlobalStore = createStateLink(
// supply the initial value for the store
// it can be read from localStore or can be set to a default
// until the data is fetched from a server, for example
{
books: [
{
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
}
],
lastUpdated: new Date()
} as Catalog);
createStateLink
has got the second argument which allows to specify validation rules, preset hooks, and other settings as for useStateLink
.
Now, create a component which uses the store state (reads and updates):
const UseStateLinkExample = () => {
const link = useStateLink(GlobalStore);
return (
<div>
{JSON.stringify(link.value)}
<button
onClick={() => link.nested.books.inferred.push({
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
})}
>
Add the second book
</button>
</div>
);
};
And all of the components, which use global store link in the rendering logic, should be nested within the observer.
const App = () => {
return (
<GlobalStore.Observer>
{
// nested components, which use GlobalStore will re-render
// when GlobalStore state is changed
}
...
</GlobalStore.Observer>
);
};
One observer per application is enough, but you can specify few to provide fine-grained observation and improve the performance by doing so.
Global state reducer
Valuelink API might not be the best to expose to consuming components. We can provide Mobx-like store functionality with clear type-safe API. For example:
// assuming it is module's private variable
const GlobalStore = createStateLink({ books: [], lastUpdated: new Date } as Catalog);
// export the observer component
export const StoreObserver = GlobalStore.Observer;
// export the hook to the store
export function useStore() {
const link = useStateLink(GlobalStore);
return {
addBook(book: Book) {
link.nested.books.inferred.push(book);
},
updateTitle(bookIndex: number, title: string) {
link.nested.books.nested[bookIndex].nested.title.set(title);
},
getBooks() {
return link.value.books;
}
};
}
Now, this store can be used as the following:
const UseStoreExample = () => {
const store = useStore();
return (
<div>
{JSON.stringify(store.getBooks())}
<button
onClick={() => store.addBook({
title: 'Code complete',
popularity: 1,
authors: ['Steve McConnell']
})}
>
Add the second book
</button>
</div>
);
};
Of course, you can create many independent stores per application.
Same result as using Redux or Mobx, but with cleaner & fewer lines of code, using smaller library overall, which handles all state lifecycle management scenarios.
Hope you enjoy using it. Feel free to contact me via github tickets or contribute to the library.
Future work
Write unit tests. The library is tested severely in scope of other projects, as some of these seriously rely on this library. However, it should not be an excuse not to write unit tests for this library. It has not been done because of lack of time.
Alternatives
- valuelink for complex local state management and two-way data binding.
- This work was initially inspired by the implementation of valuelink, but I wanted greater type-safety of the API and some other features to handle greater variety of usecases in concise and simple way.
- react-use
useList
anduseMap
libraries for local state management of arrays and objects - mobx-react-lite for global state management
License
MIT