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

0.5.5 • Public • Published

State management library based on atoms inspired by Recoil.js and Jotai.

  • No store keys required
  • State lives outside of the React Tree
  • Scoped stores are supported through a higher level API, see contextualStore
  • Works with Suspense and Transitions
  • Smooth integration with SSR
  • Strongly typed

Getting Started

store

Stores can hold any value.

Usage

const $message = store("hello world!");
const $counter = store(1);
const $list = store([1, 2, 3]);
const $promise = store(fetchImportantData());
const $person1 = store<Person>({
  name: "John",
  age: 32,
});
const $person2 = store<Person>(); // Without initial value, return type is `Person | undefined`

// Alternatively, you can destructure the store like an array to extract the setter and leaving a readonly store.
// This is useful when you don't want to expose the setter or when you only want to expose your own mutatation functions.
const [$readOnlyStore, setter] = store(false);
// $readOnlyStore.set(true)
// > Uncaught TypeError: $readOnlyStore.set is not a function

The store method supports a function to initialize the store. This is useful when you want to defer the initialization of the store until its used.

const $promise = store(() => fetchCurrentUser());

Reference

function store<T>(): Store<T | undefined>;
function store<T>(value: T): Store<T>;
function store<T>(value: () => T): Store<T>;

interface Store<T> {
  get(): T;
  set(value: T | ((value: T) => T)): void;
  subscribe(subscriber: () => void): () => void; // Returns an unsubscribe function
}

effect

Executes a function when one of the dependency stores changes.

You can return a clean up function inside the effect.

The effect call returns a function that you can call to cancel the effect.

Usage

In the following example we will use an effect to keep a Post in sync with the current Post Id.

type Post = {
  id: number;
  title: string;
  body: string;
};

const $postId = store(1);
const $post = store<Promise<Post>>();

// Update $post everytime $postId changes
const cancelEffect = effect(
  // This api is not completely similar to React's useEffect.
  // Here for example, you get the value of your dependencies as arguments of your effect function.
  (postId) => $post.set(fetchPost(postId)),
  [$postId]
);

Reference

type VoidFunction = () => void;
type CleanUp = VoidFunction;
type CancelEffect = VoidFunction;

function effect<T extends ReadonlyStore<unknown>[]>(
  effect: (...storeValues: InferStoreListOutput<T>) => void | CleanUp,
  dependencyStores: [...T]
): CancelEffect;

compute

Creates a derived a value from other stores. The value is recomputed whenever any of the dependencies change. Computed stores don't have a setter.

Usage

const $letters = store(["a", "b", "c"]);
const $numbers = store([1, 2, 3]);
const $itemsCount = compute(
  (letters, numbers) => letters.length + numbers.length,
  [$letters, $numbers]
);

console.log($itemsCount.get()); // 6
$numbers.set((currentValue) => [...currentValue, 4, 5]);
console.log($itemsCount.get()); // 8

Reference

function compute<T, U extends ReadonlyStore<unknown>[]>(
  evaluationFn: (...storeValues: InferStoreListOutput<U>) => T,
  dependencyStores: [...U]
): ReadonlyStore<T>;

contextualStore

The store API is not a replacement of React's Context API. React Context not only allows you to share a value between components, but it also does this under different scopes (Context Provider).

You can achieve the same effect by creating a Context and passing a store as value to the provider, but in order to reduce some boilerplate we provide a utility to do this for you.

Keep in mind that since this store depends on the context, you won't be able to access its value from outside the React tree like the regular store.

Usage

const [CountContextProvider, useStoreInstance] = contextualStore(0);

function Count() {
  const $counter = useStoreInstance(); // Get store from context
  const count = useStoreValue($counter); // Read store value
  return <span>{count}</span>;
}

function Counter() {
  const $counter = useStoreInstance(); // Will never trigger re-renders

  return (
    <div>
      <button onClick={() => $counter.set((v) => v - 1)}>-</button>
      <button onClick={() => $counter.set((v) => v + 1)}>+</button>
    </div>
  );
}

function App() {
  return (
    <>
      <CountContextProvider>
        <div>
          <Count />
          <Counter />
        </div>
      </CountContextProvider>
      {/* Override initial value */}
      <CountContextProvider initialValue={1000}>
        <div>
          <Count />
          <Counter />
        </div>
      </CountContextProvider>
    </>
  );
}

Reference

function contextualStore<T>(): readonly [
  StoreProviderWithoutInitial<T>,
  () => Store<T | undefined>
];

function contextualStore<T>(initialValue: T): readonly [StoreProvider<T>, () => Store<T>];

Hooks

useStore

Subscribes to a store. It returns a tuple where the first item is the store value and the second item is a function to update it.

Usage

function Count() {
  const [counter, setCounter] = useStore($counter);
  return (
    <div>
      <span>{counter}</span>
      <button onClick={() => setCounter((v) => v + 1)}>Increment</button>
    </div>
  );
}

Reference

function useStore<T>(store: Store<T>): readonly [T, (value: T) => void];

// Sets the initial value of the store in case it was created without one
// This also removes `undefined` from `Store<T | undefined>` making it safe to read.
function useStore<T>(
  store: Store<T | undefined>,
  initialValue: T
): readonly [T, (value: T) => void];

function useStore<T, TSelection>(
  store: Store<T>,
  selector: (state: T) => TSelection
): readonly [TSelection, (value: T) => void];

function useStore<T, TSelection>(
  store: Store<T>,
  initialValue: T,
  selector: (state: T) => TSelection
): readonly [TSelection, (value: T) => void];

useStoreValue

Subscribes to a store and returns the store value.

Reference

Similar to useStore.

useProxyStore

Returns a proxy object that will update the store when you mutate it. Only works with object stores.

const $counter = store({ value: 0 });

function Example() {
  const counter = useProxyStore($counter);
  return (
    <div>
      <div>{counter.value}</div>
      <button
        onClick={() => {
          counter.value += 1;
        }}
      >
        +
      </button>
    </div>
  );
}

useAwait / useAwaitResult

Reads the promise result. Suspends the component until the stored promise is either resolved or rejected.

Throws an error if the store does not contain a promise.

type Post = {
  id: number;
  title: string;
  body: string;
};

const $post = store(fetchPost({ id: 1 }));

function Post() {
  const post = useAwaitResult($post); // Component will suspend until the promise resolves
  return (
    <div>
      <div>{post.title}</div>
      <div>{post.body}</div>
    </div>
  );
}

In some cases, its not possible to create a store with an initial promise.

To prevent the hook from throwing an error, we set the promise inside an event handler on the parent component and before rendering the component that suspends.

type Post = {
  id: number;
  title: string;
  body: string;
};

const $post = store<Post>(); // No initial value. Type of $post is `Store<Post | undefined>`

function Post() {
  const post = useAwaitResult($post);
  return (
    <div>
      <div>{post.title}</div>
      <div>{post.body}</div>
    </div>
  );
}

function App() {
  const [postId, setPostId] = useState(1);
  const [postPromise, setPostPromise] = useStore($post); // `useStore` does not suspend.

  const handleNextPostClick = () => {
    const newPostId = postId + 1;
    setPostId(newPostId);
    setPostPromise(fetchPost({ id: newPostId })); // <- Set the promise here
  };

  return (
    <div>
      <button onClick={handleNextPostClick}>Next Post</button>
      {/* `postPromise` is undefined on the first render (therefore $post is empty) so we need to prevent <Post /> from rendering. */}
      {postPromise && (
        <Suspense fallback={"Loading..."}>
          <Post />
        </Suspense>
      )}
    </div>
  );
}

You might not need React Query! 🤯

usePromiseStore

Reads the promise from the store, unwraps it, and returns its state. Does not suspend the component.

Usage

const $todoPromise = store(fetchTodo({ id: 1 }));

function Todo() {
  const todo = usePromiseStore($todoPromise);

  if (todo.error) {
    return <div>An error ocurred</div>;
  } else if (!todo.data) {
    return <div>Loading...</div>;
  }

  return <div>{todo.data.title}</div>;
}

Reference

type UnwrappedPromise<T> = {
  isPending: boolean;
  data?: T;
  error?: unknown;
};

function usePromiseStore<T>(store: ReadonlyStore<Promise<T>>): UnwrappedPromise<T>;

Server Side Rendering (SSR)

IMPORTANT THINGS TO KNOW:

  • If a store is read during a server side render, its value must be serializable. This is because we have to send the value over the network in order to hydrate the stores in the client successfully.

  • In SSR frameworks, you might want to use the function syntax for stores that have a promise as initial value to prevent the promise from start executing when just importing the module. This will defer the execution of the promise until the store is read.

NextJS

In Pages Router, wrap your app in <HydrationBoundary />.

function App() {
  return (
    <HydrationBoundary>
      <Component1 />
      <Component2 />
      <Component3 />
    </HydrationBoundary>
  );
}

For the App Router the setup is slightly harder since we depend on Next's useServerInsertedHTML hook.

"use client"

import { $RDSCache, StoreContent, useHydrateCache } from "react-dollar-store"

function Provider() {
  const RDS_SCRIPT_ID = "__RDS_CACHE__" // This value can be whatever you want

  // This will hydrate the RDS cache on the client.
  useHydrateCache(RDS_SCRIPT_ID)

  // This will serialize the RDS cache on the server and place it in a script tag in the HTML document.
  useServerInsertedHTML(() => {
    return <StoreContent $store={$RDSCache} id={RDS_SCRIPT_ID} />
  })
}
// Instead of this:
const $promise = store(fetchUserInfo());

// Do this:
const $promise = store(() => fetchUserInfo());

Additional Utilities

draft

Takes a setter function that mutates the state, and returns a setter function that creates a new state. Similar to immer. Can be used with React.useState.

const $person = store({
  name: "John",
  age: 30,
});

$person.set(
  draft((state) => {
    state.name = "Jane";
  })
);

enhanceSetter

Takes a store setter function, and returns a new setter that supports mutations. Similar to draft but saves you from a lot of boilerplate when you need to mutate a store multiple times.

const $person = store({
  name: "John",
  age: 30,
});

const mutatableSetter = enhanceSetter($person.set);

mutatableSetter((state) => {
  state.name = "Jane";
});

proxify

Takes a store setter function, and returns a new setter that supports mutations. Similar to draft but saves you from a lot of boilerplate when you need to mutate a store multiple times.

const $person = store({
  name: "John",
  age: 30,
});

const proxy = proxify($person.get, $person.set);

proxy.name = "Jane" // Same as $person.set(v => ({ ...v, name: "Jane" })

Also works with React's useState:

const [state, setState] = useState({ name: "John", age: 30 });
const proxy = proxify(() => state, setState);

proxy.name = "Jane" // Same as setState(v => ({ ...v, name: "Jane" })

useProxyState

This hooks does not require a store. Its just a wrapper around useState that returns a proxy object that updates the state when you mutate it.

usePromiseState

This hooks does not require a store. It unwraps a promise and returns its state. The promise must be memoized outside of the component that uses this hook, otherwise the hook will fall in an infinite loop.

Package Sidebar

Install

npm i react-dollar-store

Weekly Downloads

10

Version

0.5.5

License

MIT

Unpacked Size

103 kB

Total Files

50

Last publish

Collaborators

  • maxizipitria