Skip to content

New kind of state library for React that heavily utilizes the React Suspense API.

License

Notifications You must be signed in to change notification settings

ziolko/active-store

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

94 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Active Store is a new state library for React that heavily utilizes the React Suspense API.

Quick start

To get started install the npm package with npm i active-store. You can then create a store as shown below:

import { createContext, useContext } from "react";
import { activeState, activeAsync, activeComputed } from "active-store";

function activeAppStore() {
  // activeState is the simplest building block of active store.
  // It keeps a piece of state and updates UI when the state changes
  // You can later access run:
  // - userLogin.get() to get the current value of the active state
  // - userLogin.set("new-value") to change the value stored in states
  const userLogin = activeState("ziolko");

  // activeAsync is similar to useQuery in react-query - it fetches data
  // for every unique set of arguments. In the case below there's only
  // one argument called - login.
  const githubProfile = activeAsync(
    (
      login: string
    ): Promise<{
      id: number;
      login: string;
      avatar_url: string;
      name: string;
    }> =>
      fetch(`https://api.github.com/users/${encodeURIComponent(login)}`).then(
        (x) => x.json()
      )
  );

  // activeComputed allows to get a computed value based on
  // activeState and activeAsync. It uses React Suspense api to wait
  // for activeAsync to resolve
  const profile = activeComputed(() => {
    // get currently selected github login. You will see a lot of `.get()`
    // in the codebase using active-store
    const login = userLogin.get();

    // start fetching data from activeAsync for currently
    // selected github login. The line below will suspend until
    // github profile finishes loading.
    // There's no need for special handling of the async loading state.
    return githubProfile.get(login);
  });

  // Of course you can combine computed values in activeComputed, too
  const userName = activeComputed(() => profile.get().name);

  return {
    userLogin,
    profile,
    userName,
  };
}

const store = activeAppStore(); // Create an instance of the store

// Define a helper hook 'useStore' to get an instance
// of the store in your components
const storeContext = createContext<ReturnType<typeof activeAppStore>>(store);
export const useStore = () => useContext(storeContext);

You are now all set to use the store in your app:

import "./App.css";
import { useStore } from "./store";
import { useActive, ActiveBoundary } from "active-store";

export default function App() {
  return (
    <div>
      <CurrentUserPicker />
      {/*  
        Active boundary wraps a section of the app that loads and fails
        together 
        - fallback is used while data required to load child components
      is loading. 
        - errorFallback is used when any of the data fails to load
      with an exception
      */}
      <ActiveBoundary
        fallback="Loading..."
        errorFallback="There was an error while loading the data :("
      >
        <GithubProfile />
      </ActiveBoundary>
    </div>
  );
}

function CurrentUserPicker() {
  const store = useStore();
  const currentLogin = useActive(store.userLogin);
  const options = ["stephencelis", "lidel", "arogozhnikov", "ziolko"];
  return (
    <>
      {options.map((login) => (
        <button
          key={login}
          onClick={() => store.userLogin.set(login)}
          style={{ background: currentLogin === login ? "red" : "transparent" }}
        >
          {login}
        </button>
      ))}
    </>
  );
}

function GithubProfile() {
  const store = useStore();

  // React will suspend until the data is loaded
  const profile = useActive(store.profile);

  // React will suspend until the data is loaded
  const name = useActive(store.userName);

  return (
    <div>
      {name} <img src={profile.avatar_url} width={80} />
    </div>
  );
}

Examples

Reference

activeState

The simplest building block of the app state. It's like the useState hook.

const state = activeState<T>(initialState: T, options?: { 
  // onSubscribe is called when first subscriber subscribes to the state. 
  // The callback returned from onSubscribe is called when last subscriber unsubscribes
  onSubscribe?: () => () => void;
  // Number of miliseconds the data will be cached after 
  // last subscriber unsubscribes (defaults to infinity)
  gcTime?: number;
});


// Returns the current value of the state
state.get();

// Sets new value for the state. Trigers re-renders of
// components that depend on it. Triggers invalidation
// on activeComputed that depend on it.
state.set(newValue: T);

// Manually subscribes to changes in the state.
// Takes a listener as a parameter. Returns unsubscribe function.
state.subscribe(listener: () => any) => () => void;

Alternatively, you can provide a factory function as an initialState:

const greetings = activeState((name: string) => `Hello ${name}`, {
  onSubscribe(name: string) {
    console.log(`Subscribed to ${name}`);
    return () => { console.log(`Unsubscribed from ${name}`); }
  },
});

console.log(greetings.get('Adam')); // will print "Hello Adam"

// Set greetings for "Adam" to "Hi Adam"
// Notice that the value comes first, and key comes after it
greetings.set('Hi Adam', 'Adam');

activeAsync

This is heavily based on React Query, so if you are familiar with this library you will feel like home.

// Create an async state with a factor function returning promise.
// Example: https://stackblitz.com/edit/vitejs-vite-mosens?file=src%2Fstore.ts
// Important: The async state re-fetches based only on the provided parameters.
// If you use e.g. activeState in the factory function, updating it's state
// won't trigger a re-fetch.
const query = activeAsync(factory: (...args: P) => Promise<R>, options?: { 
  // Number of retries in case of failure
  retry?: number | false;
  // onSubscribe is called when first subscriber subscribes to the state. 
  // The callback returned from onSubscribe is called when last subscriber unsubscribes
  onSubscribe?: (...args: P) => () => void;
  // Number of miliseconds the data will be cached after 
  // last subscriber unsubscribes (defaults to infinity)
  gcTime?: number;
});


// If the query for "hello" "world" has already resolved, returns the value.
// If it rejected it throws the rejection reason as an exception
// If query is pending, it throws a React Suspense error that
// suspenses rendering React components and activeComputed
// (more on this below).
query.get("hello", "world");

// Returns the current state of the query. The returned state
// has the following fields that are very similar to react-query
// - status: "pending" | "success" | "error";
// - isPending: boolean;
// - isSuccess: boolean;
// - isError: boolean;
// - isRefetching: boolean;
// - isFetching: boolean;
// - isStale: boolean;
// - data?: R;
// - error?: any;
// - dataUpdatedAt?: number;
// - errorUpdatedAt?: number;
// - fetchStatus: "fetching" | "paused" | "idle";
query.state("hello", "world");

// Sets new value for the async state.
state.set(newValue: T);

// Returns a promise for query for given parameters
query.getPromise("hello" ,"world");

// Prefetch data - use it if you're sure that the data will be needed soon
query.prefetch("hello", "world") ;

// Invalidate query for given parameters - will mark data as stale
// and refetch if any component uses the query (either directly, or
// through activeComputed)
query.invalidateOne("hello", "world");

// Invalidate query for all entries for which selector returns true.
// This marks data as stale and refetch queries used in any visible
// React component (either directly or through activeComputed)
// Options:
// - reset (default false) - reset the query to the initial state
//                           (idle, with no data or error)
query.invalidate(
  selector: (...args: P) => boolean,
  options?: { reset?: boolean }
);

activeComputed

Creates a computed state based on any other active state. If any React component is subscribed to it, it recomputes automatically whenever any of its dependency changes.

// The provided factory function must not be async
// (or return a Promise) - TypeScript will complain when this happens.
// You can use any combination of activeState, activeAsync, or
// activeComputed inside.
const computed = activeComputed(factory: (...args: P) => R, options?: { 
  // Number of miliseconds the data will be cached after 
  // last subscriber unsubscribes (defaults to infinity)
  gcTime?: number;
});

// As active computed can depend on active query, it has to
// follow its async semantics:
// - If the computed for "hello" "world" has already resolved,
//   returns the value.
// - If it rejected it throws the rejection reason
//   as an exception
// - If it's pending, it throws a React Suspense error that
//   suspends rendering React components and activeComputed
//   that depend on it (more on this below).
computed.get("hello", "world");

// Returns the current state of the computed value.
// The returned state has the following fields:
// - status: "pending" | "success" | "error";
// - data?
// - error?
computed.state("hello", "world");

// Prefetch data - use it if you're sure that the data will be needed soon
computed.prefetch("hello", "world");

useActive

It's like useSelector from Redux. It connects your components with store.

// Subscribes to the value of the activeComputed property.
// Every time the value changes, component is re-rendered.
const value = useActive(() => store.computed.get(userId));

// If `get` doesn't take any parameters, you can just pass
// a reference to the getter:
const value = useActive(store.currentUser.get);

// For convenience you can skip the `.get` part and
// `useActive` will call it automatically:
const value = useActive(store.currentUser);

getActive

Compute value of an expression. If any active query or active computed is pending, it will wait until it fully resolves

const query = activeAsync(
  () => new Promise((res) => setTimeout(() => res("Hello"), 1000))
);

// Will await 1s until query resolves. 
// Returned value will be equal "Hello World"
const value = await getActive(() => `${query.get()} World` ); 

ActiveBoundary

Active boundary wraps a section of the app that loads and fails together. It's basically <Suspense> and React error boundary in a single component.

  • fallback is used while data required to load child components is loading.
  • errorFallback is used when any of the data fails to load with an exception

You can find an example of using it in the "Quick start" section.

How does suspending activeAsync and activeComputed work

This library uses the React Suspense API for handling loading state. Under the hood it (ab)uses exceptions.

When activeComputed tries to get value of an active query (e.g. user.get('mateusz')) that is currently in a pending state, an special kind of exception is thrown. The trick is that the exception is also a Promise.

When the promise resolves, React re-renders the component so activeComputed recomputes the value and this time the query is already resolved so it successfully computes the value.

License

The project is licensed under the MIT permissive license

About

New kind of state library for React that heavily utilizes the React Suspense API.

Topics

Resources

License

Stars

Watchers

Forks

Languages