This package has been deprecated

Author message:

The package has been moved to @hookstate/initial.

react-hookstate-initial
TypeScript icon, indicating that this package has built-in type declarations

0.10.0 • Public • Published

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)

API guide table of contents:

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 and React.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([...]) or set((prevState) => [...]) sets new value of the array state. It has got the same behaviour as the second value returned from the React.useState function

  • merge({...}) or merge((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 state

  • update(index, newElementValue) or update(index, (prevElementValue) => newElementValue) sets new value of the array state, updating the element of an array by the specified index

  • concat([...]) or concat((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 state

  • push(newElement) sets new value of the array state, adding new element to the end

  • pop() sets new value of the array state, removing the last element

  • insert(indexWhereToInsert, newElement) sets new value of the array state, inserting the new element by the specified index

  • remove(index) sets new value of the array state, removing the element by the specified index

  • swap(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([...]) or set((prevState) => [...]) sets new value of the object state. It has got the same behaviour as the second value returned from the React.useState function
  • merge({...}) or merge((prevState) => ({...})) sets new value of the object state, updating the specified properties
  • update(propertyKey, newPropertyValue) or update(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, of Catalog type in this example
  • and set(...) or set((prevState) => ...) - function which allows to set new value state, of Catalog type in this example, similarly to the setState variable returned by React.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 no errors. 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 and modified status becomes different between parent link and local link. This allows parent components to react on valid and modified 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 and useMap libraries for local state management of arrays and objects
  • mobx-react-lite for global state management

License

MIT

Readme

Keywords

none

Package Sidebar

Install

npm i react-hookstate-initial

Weekly Downloads

0

Version

0.10.0

License

MIT

Unpacked Size

42.3 kB

Total Files

9

Last publish

Collaborators

  • avkonst