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

2.1.0 • Public • Published

React Quarks

React Quarks is a simple, lightweight and easy to use state management library for React with full support for Asynchronous State Updates

Table of Contents

  1. Installation
  2. Basics
  3. Asynchronous Updates
    1. Canceling pending updates
  4. Readonly quark state typings
    1. Example Without Read-only
    2. Example With Read-only
  5. Limitations
    1. Function as state value
    2. Promise as state value
  6. Dictionaries, Arrays, Selectors, Actions and Middlewares
    1. Selectors
    2. Selectors with arguments
    3. Actions
    4. Side Effects
    5. Subscription
    6. Middlewares
      1. Creating a Middleware
      2. Global Middleware
      3. Included Middlewares
        1. Immer Middleware
        2. Catch Middleware
        3. Debug History Middleware
  7. SSR

Installation

npm install react-quarks

OR

yarn add react-quarks

Basics

Import the quark method from the react-quarks package.

import { quark } from "react-quarks";

Create a new Quark

const counter = quark(0);

To declare the quark type you will need to assert the desired type into the initial value (This is a limitation of TypeScript and how the generic types inference works, if you were to specify the type with the <> symbols you'd have to also manually define the types for all of the quark selectors, actions and middlewares as well, no inference):

const listOfNumbers = quark([] as number[]);
// OR
const initialValue: number[] = [];
const listOfNumbers = quark(initialValue);

Access data in the Quark outside React

const currentCounterValue = counter.get();

Update the state of the Quark outside React

With a simple .set(value)

counter.set(counter.get() + 1);

With a dispatch function

counter.set((currentState) => currentState + 1);

With a promise

counter.set(Promise.resolve(counter.get() + 1));

With a dispatch function returning a promise

counter.set((currentState) => Promise.resolve(currentState + 1));

Use the Quark within a React functional component

const MyComponent: React.FC = () => {
  const counterState = counter.use();

  const incrementCounter = () => {
    counterState.set((state) => state + 1);
  };

  return (
    <div>
      <p>Current count: {counterState.value}</p>

      <button onClick={incrementCounter}>Increment</button>
    </div>
  );
};

Asynchronous Updates

Quarks can accept asynchronous updates out of the box.

To perform an asynchronous state update simply pass a Promise object to the set method like this:

const counter = quark(0);

// ...

counter.set(Promise.resolve(1));

or a function that returns a Promise:

const counter = quark(0);

// ...

counter.set(() => Promise.resolve(1));

All asynchronous updates are tracked by the Quark instance, and any state update will cancel all the previous non-resolved async updates. With this it's assured that no race-conditions shall occur when using Quarks async updates.

Canceling pending updates

In case an asynchronous (or synchronous) update that has been dispatched cannot be finished it can be cancelled by throwing a special class CancelUpdate.

import { CancelUpdate } from "react-quarks";

const data = quark(
  {
    /* some initial data */
  },
  {
    actions: {
      async updateWithNewData() {
        try {
          // We request new data from the server
          const result = await fetchNewData();
          // Request was successful, update the state with the result
          return result;
        } catch (e) {
          // Request failed, update is cancelled
          throw new CancelUpdate();
        }
      },
    },
  }
);

When an action function throws, the quark state update does not happen regardless of what has been thrown, however if the thrown value is not an CancelUpdate instance, that event will be logged to the console as an error and propagated up to the initial caller.

Readonly quark state typings

It is possible to make Quark states have a readonly type when accessed via .use().value or .get() or a selector. This can help avoid errors that may occur when changing properties of a state, since by design states of Quarks (and React states) are intended to be immutable.

You can enable this feature by extending the global Quarks.TypeConfig interface with a ENABLE_READONLY_STATES property:

declare global {
  namespace Quarks {
    interface TypeConfig {
      ENABLE_READONLY_STATES: true;
    }
  }
}

Example Without Read-only

const myQuark = quark({ foo: { bar: 0 } });
const value = myQuark.get(); // const value: { foo: { bar: number } }

value.foo.bar = 1; // OK, no errors

Example With Read-only

const myQuark = quark({ foo: { bar: 0 } });
const value = myQuark.get(); // const value: { readonly foo: { readonly bar: number } }

value.foo.bar = 1; // Error: Cannot assign to 'bar' because it is a read-only property.

Limitations

It's impossible to assign a Function or a Promise object as the Quark value, since any functions or promises will automatically be "unpacked" by the Quark set() function (even if they are nested, i.e. () => () => void).

If you must have a promise or a function as the value of the Quark then wrap it within a object like so:

Function as state value

const quarkWithAFunction = quark({ fn: () => {} });

const someFunction () => {
    // ...
};

quarkWithAFunction.set({ fn: someFunction });

Promise as state value

const quarkWithAPromise = quark({ p: Promise.resolve() });

const somePromise = new Promise((resolve) => {
  // ...
});

quarkWithAPromise.set({ p: somePromise });

Dictionaries, Arrays, Selectors, Actions and Middlewares

If your Quark holds arrays of data or dictionaries using just use, get and set can become cumbersome, to make it easier to use your Quarks you can define actions, selectors and middlewares that will help minimize the boilerplate and ease the development.

Selectors

Often subscribing to the whole dictionary or array may not be something you want, take for example this Quark:

const siteSettings = quark({
  title: "My Webpage",
  theme: "dark",
});

Here both title and theme are stored in the same Quark, this means that if we change the value of the title all component that "use" this Quark will update, even if all they actually use is the theme property (in which case the update is unnecessary). To solve this issue we can use selectors.

Selectors can be used in two ways

  1. via the builtin hook useSelector
  2. or by adding custom selector to the Quark definition
useSelector
const PageHeader: React.FC = () => {
  const title = siteSettings.useSelector((state) => state.title);

  return <h1>{title}</h1>;
};
custom selector

First we will need to change how the Quark is defined:

const siteSettings = quark(
  {
    title: "My Webpage",
    theme: "dark",
  },
  {
    selectors: {
      useTitle(state) {
        return state.title;
      },
    },
  }
);

And with this the useTitle hook will be added to our Quark.

const PageHeader: React.FC = () => {
  const title = siteSettings.useTitle();

  return <h1>{title}</h1>;
};

Both of the above solution achieve the same thing - the PageHeader component will re-render whenever the title changes but not when the theme changes.

It's worth mentioning that selectors can be used to do much more than that, the returned value from the selector doesn't have to be a property of the Quark state, it can be anything, and the selector will cause the component to update only when that returned value is different from the previous one.

Selectors with arguments

Selectors can take custom arguments too. It can be done by simply adding arguments to the selector function after the Quark State, like so

const siteSettings = quark(
  {
    title: "My Webpage",
    theme: "dark",
  },
  {
    selectors: {
      useTitleLetter(state, letterIndex: number) {
        return state.title[letterIndex];
      },
    },
  }
);

and then,

const PageHeader: React.FC = () => {
  const letter = siteSettings.useTitleLetter(3);

  return <h1>Third letter of the title is: {letter}</h1>;
};

Actions

When the Quark holds an array or a dictionary updating the state with the set method may create a boilerplate, for this reason you might want to create a helper functions that will contain all of the repeatable logic in one place. With Actions you can more tightly integrate those helpers with the Quark.

Let's again consider the above siteSettings example. If you'd want to update the title you'd need to do something like this:

siteSettings.set((state) => ({ ...state, title: "My new website title" }));

All of this for changing one property, and it would only get worse if that property was deeply nested within the siteSettings.

To avoid this unnecessary boilerplate you can add actions to the Quark object:

const siteSettings = quark(
  {
    title: "My Webpage",
    theme: "dark",
  },
  {
    actions: {
      setTitle(state, newTitle: string) {
        return { ...state, title: newTitle };
      },
      // async actions are also possible
      async setThemeAfter1sec(state, theme: string) {
        await new Promise((r) => setTimeout(r, 1000));
        return { ...state, theme };
      },
    },
  }
);

And with that the actions can be used like so:

siteSettings.setTitle("My new website title");
siteSettings.setThemeAfter1sec("light");

Actions always take the Quark state as it's first argument, and optionally some other following arguments. The method exposed by the Quark will be called the same as the one in the Quark definition but without the first argument with Quark state.

Side Effects

Sometimes you may want something to happen when the Quark state changes. Quark effects allow for just that.

For example let's say we have a Quark that holds some information that we expect will often change throughout the life of our app and we want to have a timestamp of every change to that information. Instead of adding a new 'Date.now()' on every set call we can add an effect like so:

const data = quark(
  {
    myData: "some data",
    lastUpdated: 0,
  },
  {
    sideEffect: (prevState, newState, set) => {
      if (prevState.myData !== newState.myData) {
        set({ ...newState, lastUpdated: Date.now() });
      }
    },
  }
);

Now whenever we change the myData property to a different value myDataEffect will fire and set the lastUpdated property to the current time.

Effect methods take three argument:

  • first is the previous state of the Quark
  • second is the new or rather current state of the Quark
  • third is a set function that allows for updating the Quark state

Subscription

Quarks can also be subscribed to outside of React.

Example
const counter = quark(0);

const subscription = counter.subscribe(
  (currentState: number, cancel: () => void) => {
    // run on Quark state change
  }
);

subscription.cancel(); // Cancels the subscription (equivalent of removeListener in event emitter's)

subscribe() method takes a callback as it's argument that's invoked whenever the Quark state changes. That callback can take up to two arguments, the current state of the Quark and a method for cancelling the subscription from within the callback. Subscription can also be cancelled via a method returned returned by the subscribe() as shown in the example.

Middlewares

Middlewares give you the ability to intercept any state updates and modify or prevent them from occurring as well as observe actions sent to the Quarks.

A middleware ought to be a function taking up to 5 arguments:

  • arg_0 - function getState(): T - method which returns the current Quark state value
  • arg_1 - action: SetStateAction<T, M> - dispatched value, this is the same as what is provided to the set() function argument
  • arg_2 - function resume(v: SetStateAction<T, M>): void - this method will resume the update flow, value provided to it will be forwarded to the next middleware
  • arg_3 - function set(v: SetStateAction<T, M>): void - this method allows to break out from the update flow and set the state immediately bypassing any following middlewares
  • arg_4 - updateType: 'sync' | 'async' - this argument indicates if the source of the update was synchronous or asynchronous, whatever is provided to the set() method will always have a sync type at first, then if the passed value is a Promise or a function returning a Promise, the resolved value from that Promise will be given to the middleware with a type of async.
Example

A naive middleware that will catch any errors thrown by an action.

const catchMiddleware: QuarkMiddleware<number, never> = (
  getState,
  action,
  resume
) => {
  try {
    resume(action);
  } catch (e) {
    console.error("An error occurred during state update!");
  }
};

const counter = quark(0, { middlewares: [catchMiddleware] });

// The action in this case will be a function
counter.set(() => {
  throw new Error();
}); // Output: 'An error occurred during state update!'

Creating a Middleware

One of the uses of middleware can be to extend the functionality of a Quark.

As an example, imagine you have a Quark storing a number, but you'd want to be able to set() it's state with a string. The goal is to have the Quark contain always a numeric value, but the set() function to accept both numbers and strings, and not only that, but the action passed to the set() could also be a Promise resolving any of the two, or a callback that returns either.

This can be easily achieved with a right middleware.

Example
const numParserMiddleware: QuarkMiddleware<number, string> = (
  getState,
  action,
  resume
) => {
  // If the action is a string, rather than a number, parse it to a number and continue the update with it
  if (typeof action === "string") {
    const num = Number(action);

    if (isNaN(num)) throw new Error("This string is not parsable to a number!");

    return resume(num);
  }

  // otherwise, continue with the original action
  resume(action);
};

const counter = quark(0, { middlewares: [numParserMiddleware] });

counter.set("2"); // OK
counter.get(); // 2 as a number type

counter.set(() => "123"); // OK
counter.get(); // 123 as a number type

counter.set(() => Promise.resolve("777")); // OK
// ...
counter.get(); // 777 as a number type

If you want the middleware to play nicely with TypeScript don't forget to add the QuarkMiddleware type to your middleware (as shown in the example). QuarkMiddleware is a type that takes in two generics, first is the type of the Quark (as in the type that the get() method should return), and the other is a type that is extending the Quark action (ie. the type that can be now additionally accepted in the set() method aside from the actual Quark type).

Global Middleware

It is also possible to add a global middleware. Global middlewares will be automatically added to all quarks.

To add a global middleware use the addGlobalQuarkMiddleware function, or to overwrite all the global middlewares setGlobalQuarkMiddlewares. You can also lookup all the current global middlewares with getGlobalQuarkMiddlewares.

Included Middlewares

react-quarks library includes a few Middleware factories to use out of the box.

  • Immer Middleware - extends the standard function setters of quarks with the immer library to allow for updating state by detecting changes made on the current state provided to that functions. When this middleware is used it's possible to update quarks by mutating state properties directly, when within action methods or set functions (ex. quark.set(current => { current.foo = newValue; return current; })))
  • Catch Middleware - a middleware that provides you a way for catching errors thrown by the set state action callbacks and promises.
  • Debug History Middleware - a middleware that provides you a way of tracking the Quark update actions and the current state of the Quark.
Immer Middleware
import {
  quark,
  createImmerMiddleware,
  addGlobalQuarkMiddleware,
} from "react-quarks";

addGlobalQuarkMiddleware(createImmerMiddleware({ mapAndSetSupport: true }));

const state = quark(
  { value: "foo" },
  {
    actions: {
      changeValue(currentState, setTo: string) {
        currentState.value = setTo;
        return currentState;
      },
    },
  }
);

state.changeValue("bar");
state.get(); // => { value: "bar" }

// Works even with async updates

state.set(async (currentState) => {
  currentState.value = await fetch(/* ... */);
  return currentState;
});
Catch Middleware
import { quark, createCatchMiddleware } from "react-quarks";

const catchErrorMiddleware = createCatchMiddleware({
  catch: (e) => {
    console.log("An error has been caught!", e);
    // handle the error
  },
});

const counter = quark(0, {
  middlewares: [catchErrorMiddleware],
});

counter.set(() => {
  throw new Error("Update Failed!");
});
// Output: 'An error has been caught! Error: Update Failed!'

It is recommended for this middleware to be the very first middleware provided in the middlewares array.

Debug History Middleware
import { quark, createDebugHistoryMiddleware } from "react-quarks";

const counterDebugMiddleware = createDebugHistoryMiddleware({
  name: "Counter Quark",
  trace: false, // When enabled a stack trace will be generated for each dispatched set state action
  realTimeLogging: true, // When enabled each set state action will be logged to the console in real time
  useTablePrint: false, // When enabled the history will be printed to the console with a `console.table()`, otherwise `console.log()` will be used
});

const counter = quark(0, {
  middlewares: [counterDebugMiddleware],
});

It is recommended for this middleware to be the last middleware provided in the middlewares array.

When this middleware is used a method in the global scope will be created that will allow the developer to view the quark update history via console.

To show the history, open the console and invoke the method: printQuarkHistory()

By default a history of all quarks with this middleware will be logged to the console. You can filter out which Quark is to be shown and how many history entries are to be displayed by specifying the options argument:

printQuarkHistory({
  name: "Counter Quark", // Name of the specific Quark to show history of. Default is show all
  showLast: 10, // Number of set state action's history entries to show. Default is 16
  useTablePrint: true, // When enabled the history will be printed to the console with a `console.table()`, otherwise `console.log()` will be used
});

SSR

To support Server Side Rendering, Quarks provide a way to serialize them (on the server) and then hydrate (on the client). Each quark that is going to be serialized must have a unique name.

Fist you will need to install serialize-javascript on the backend. This package is required for the serializeQuarks() function to work.

npm i serialize-javascript

OR

yarn add serialize-javascript

Then you can use it as follows:

// Server Side
import Express from "express";
import { renderToString } from "react-dom/server";
import { quark, serializeQuarks } from "react-quarks";

const serverHeader = quark("Hello World", { name: "header" });

const Home = () => {
  const header = serverHeader.use();

  return <h1>{header.value}</h1>;
};

const app = Express();

app.get("/", (req, resp) => {
  const html = renderToString(<Home />);
  const serialized = serializeQuarks();

  return `
    <html>
      <head>
        <script>
          window.__PRELOADED_STATE__ = ${serialized}; // quotation marks are already included
        </script>
      </head>
      <body>
        <div id="root">
          ${html}
        <div>
      </body>
    </html>
  `;
});

app.listen(80);

// Client side
import ReactDOM from "react-dom";
import { quark, hydrateQuarks } from "react-quarks";

const clientHeader = quark("", { name: "header" }); // the same name as in the `serverHeader`

const Home = () => {
  const header = clientHeader.use();

  return <h1>{header.value}</h1>;
};

if (window.__PRELOADED_STATE__) {
  hydrateQuarks(window.__PRELOADED_STATE__);
}

// Serialized data is not needed after hydration
delete window.__PRELOADED_STATE__;

ReactDOM.hydrate(<Home />, document.getElementById("root"));

Dependents (0)

Package Sidebar

Install

npm i react-quarks

Weekly Downloads

1

Version

2.1.0

License

MIT

Unpacked Size

5.2 MB

Total Files

102

Last publish

Collaborators

  • ncpa0cpl