1.0.0 • Published 1 year ago

flowbyte v1.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
1 year ago

Flowbyte is a concise and efficient state management solution that is designed to be lightweight, speedy, and scalable. It is based on simplified Flux principles and features a user-friendly API that utilizes hooks. Unlike other solutions, it does not rely on boilerplate code or impose specific development preferences.

Although it may look cute, don't underestimate this state management solution. It has been meticulously crafted to address common issues, such as the notorious zombie child problem, react concurrency, and context loss between mixed renderers. These problems have been extensively tackled to ensure that this state manager provides a superior experience compared to its peers in the React space. Therefore, it's worth considering and not dismissing it based on its appearance alone.

npm install flowbyte # or yarn add flowbyte

:warning: If you happen to be a TypeScript user, please make sure to check out the TypeScript Usage section of this readme. While this document is primarily intended for JavaScript developers.

Getting started: Creating a store

Your store is implemented as a hook, which can hold a wide range of data types such as primitives, objects, and functions. It's important to update the state immutably, and the set function is designed to help with this by merging the new state with the old state.

import { create } from "flowbyte";

const useSharkStore = create((set) => ({
  sharks: 0,
  increasePopulation: () => set((state) => ({ sharks: state.sharks + 1 })),
  removeAllSharks: () => set({ sharks: 0 }),
}));

Bind your components to it, and you're all set!

One of the advantages of this solution is that you can use the hook anywhere, without needing to worry about providers. Simply select the state that you want to access, and your component will automatically re-render whenever changes are made to that state.

function SharkCounter() {
  const sharks = useSharkStore((state) => state.sharks);
  return <h1>{sharks} sharks around me !</h1>;
}

function Controls() {
  const increasePopulation = useSharkStore((state) => state.increasePopulation);
  return <button onClick={increasePopulation}>Increase Population</button>;
}

Why flowbyte over redux?

  • Simple and easy-to-use API for managing state in React applications.
  • Highly flexible and can be used with any state management pattern or architecture.
  • Lightweight library with a small footprint and no external dependencies.
  • This solution prioritizes the use of hooks as the primary method for consuming state.
  • Unlike other solutions, this one does not wrap your application in context providers.
  • Offers the ability to inform components in a transient manner, without triggering a re-render.

Why flowbyte over context?

  • Less boilerplate
  • Components are only re-rendered when changes occur
  • This solution follows a centralized, action-based approach to state management

Recipes

Fetching data

While it's possible to do so, it's important to keep in mind that this approach will cause the component to update on every state change.

const state = useSharkStore();

Selecting multiple state slices

By default, it detects changes using strict equality (old === new), which is efficient for picking up atomic state changes.

const tooth = useSharkStore((state) => state.tooth);
const fin = useSharkStore((state) => state.fin);

If you need to create a single object that contains multiple state picks, similar to redux's mapStateToProps, you can inform this solution that you want the object to be shallowly diffed by passing the shallow equality function.

import { shallow } from "flowbyte/shallow";

// Object pick, re-renders the component when either state.tooth or state.fin change
const { tooth, fin } = useSharkStore((state) => ({ tooth: state.tooth, fin: state.fin }), shallow);

// Array pick, re-renders the component when either state.tooth or state.fin change
const [tooth, fin] = useSharkStore((state) => [state.tooth, state.fin], shallow);

// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useSharkStore((state) => Object.keys(state.treats), shallow);

If you require more control over how and when re-rendering occurs, you can supply a custom equality function to this solution.

const treats = useSharkStore(
  (state) => state.treats,
  (oldTreats, newTreats) => compare(oldTreats, newTreats)
);

Overwriting state

The set function includes a second argument, which is false by default. This argument specifies that the new state should replace the existing state, rather than merging with it. When using this option, be cautious not to inadvertently overwrite important parts of your state, such as actions.

import omit from "lodash-es/omit";

const useSharkStore = create((set) => ({
  whiteShark: 1,
  tigerShark: 2,
  deleteEverything: () => set({}, true), // clears the entire store, actions included
  deleteTigerShrak: () => set((state) => omit(state, ["tigerShark"]), true),
}));

Async actions

When you're ready to update your state, simply call the set function. One of the advantages of this solution is that it doesn't matter whether your actions are synchronous or asynchronous.

const useSharkStore = create((set) => ({
  sharks: {},
  fetch: async (ocean) => {
    const response = await fetch(ocean);
    set({ sharks: await response.json() });
  },
}));

Read from state in actions

In addition to updating your state with a new value, the set function also allows for function-based updates using set(state => result). However, you can still access the current state outside of set by using the get function.

const useSoundStore = create((set, get) => ({
  sound: "no-sound",
  action: () => {
    const sound = get().sound;
    // ...
  }
})

Reading/writing state and reacting to changes outside of components

Occasionally, there may be situations where you need to access the state in a non-reactive manner, or perform actions on the store. To accommodate these scenarios, Flowbyte provides utility functions that are attached to the prototype of the resulting hook.

const useSharkStore = create(() => ({ teeth: true, fin: true, tail: true }));

// Getting non-reactive fresh state
const teeth = useSharkStore.getState().teeth;
// Listening to all changes, fires synchronously on every change
const unsub1 = useSharkStore.subscribe(console.log);
// Updating state, will trigger listeners
useSharkStore.setState({ teeth: false });
// Unsubscribe listeners
unsub1();

// You can of course use the hook as you always would
const Component = () => {
  const teeth = useSharkStore((state) => state.teeth);
  ...

Using subscribe with selector

If you need to subscribe to a specific selector, you can utilize the subscribeWithSelector middleware to accomplish this.

When using the subscribeWithSelector middleware, the subscribe function accepts an additional signature:

subscribe(selector, callback, options?: { equalityFn, fireImmediately }): Unsubscribe
import { subscribeWithSelector } from "flowbyte/middleware";
const useSharkStore = create(subscribeWithSelector(() => ({ teeth: true, fin: true, tail: true })));

// Listening to selected changes, in this case when "teeth" changes
const unsub2 = useSharkStore.subscribe((state) => state.teeth, console.log);
// Subscribe also exposes the previous value
const unsub3 = useSharkStore.subscribe(
  (state) => state.teeth,
  (teeth, previousTeeth) => console.log(teeth, previousTeeth)
);
// Subscribe also supports an optional equality function
const unsub4 = useSharkStore.subscribe((state) => [state.teeth, state.fin], console.log, { equalityFn: shallow });
// Subscribe and fire immediately
const unsub5 = useSharkStore.subscribe((state) => state.teeth, console.log, {
  fireImmediately: true,
});

Using flowbyte without React

It is possible to utilize Flowbyte core without depending on React. The sole distinction is that instead of returning a hook, the create function will provide API utilities.

import { createStore } from "flowbyte/vanilla";

const store = createStore(() => ({ ... }));
const { getState, setState, subscribe } = store;

export default store;

The useStore hook can be used with a vanilla store.

import { useStore } from "flowbyte";
import { vanillaStore } from "./vanillaStore";

const useBoundStore = (selector) => useStore(vanillaStore, selector);

:warning: It should be noted that any middleware that alters set or get functions will not have any effect on getState and setState.

Transient updates (for often occurring state-changes)

Components can bind to a specific state section using the subscribe function, which won't trigger a re-render on changes. For automatic unsubscription upon unmounting, it's best to combine it with useEffect. Utilizing this approach can significantly improve performance when direct view mutation is permitted

const useBiteStore = create(set => ({ bites: 0, ... }));

const Component = () => {
  // Fetch initial state
  const biteRef = useRef(useBiteStore.getState().bites);
  // Connect to the store on mount, disconnect on unmount, catch state-changes in a reference
  useEffect(() => useBiteStore.subscribe(
    state => (biteRef.current = state.bites);
  ), [])
  ...

Sick of reducers and changing nested state? Use Immer!

Eliminating nested structures can be a tedious task. Have you ever considered using immer?

import produce from "immer";

const useHabitatStore = create((set) => ({
  habitat: { ocean: { contains: { a: "shark" } } },
  clearOcean: () =>
    set(
      produce((state) => {
        state.habitat.ocean.contains = null;
      })
    ),
}));

const clearOcean = useHabitatStore((state) => state.clearOcean);
clearOcean();

Middleware

You have the flexibility to compose your store functionally in whichever way you prefer.

// Log every time state is changed
const log = (config) => (set, get, api) =>
  config(
    (...args) => {
      console.log("applying", args);
      set(...args);
      console.log("new state", get());
    },
    get,
    api
  );

const useFishStore = create(
  log((set) => ({
    fishes: false,
    setFishes: (input) => set({ fishes: input }),
  }))
);

Persist middleware

It is possible to persist your store's data using any storage mechanism of your choice.

import { create } from "flowbyte";
import { persist, createJSONStorage } from "flowbyte/middleware";

const useFishStore = create(
  persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set({ fishes: get().fishes + 1 }),
    }),
    {
      name: "fish-storage", // unique name
      storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
    }
  )
);

Immer middleware

Immer is available as middleware too.

import { create } from "flowbyte";
import { immer } from "flowbyte/middleware/immer";

const useFishStore = create(
  immer((set) => ({
    fishes: 0,
    addFishes: (by) =>
      set((state) => {
        state.fishes += by;
      }),
  }))
);

Can't live without redux-like reducers and action types?

const types = { increase: "INCREASE", decrease: "DECREASE" };

const reducer = (state, { type, by = 1 }) => {
  switch (type) {
    case types.increase:
      return { grumpiness: state.grumpiness + by };
    case types.decrease:
      return { grumpiness: state.grumpiness - by };
  }
};

const useGrumpyStore = create((set) => ({
  grumpiness: 0,
  dispatch: (args) => set((state) => reducer(state, args)),
}));

const dispatch = useGrumpyStore((state) => state.dispatch);
dispatch({ type: types.increase, by: 2 });

Alternatively, you can utilize our redux-middleware, which not only wires up your main reducer, sets the initial state, and adds a dispatch function to the state and vanilla API, but also makes the process easier.

import { redux } from "flowbyte/middleware";

const useGrumpyStore = create(redux(reducer, initialState));

Redux devtools

import { devtools } from "flowbyte/middleware";

// Usage with a plain action store, it will log actions as "setState"
const usePlainStore = create(devtools(store));
// Usage with a redux store, it will log full action types
const useReduxStore = create(devtools(redux(reducer, initialState)));

One redux devtools connection for multiple stores

import { devtools } from "flowbyte/middleware";

// Usage with a plain action store, it will log actions as "setState"
const usePlainStore1 = create(devtools(store), { name, store: storeName1 });
const usePlainStore2 = create(devtools(store), { name, store: storeName2 });
// Usage with a redux store, it will log full action types
const useReduxStore = create(devtools(redux(reducer, initialState)), , { name, store: storeName3 });
const useReduxStore = create(devtools(redux(reducer, initialState)), , { name, store: storeName4 });

By assigning distinct connection names, you can keep the stores separate in the Redux DevTools, which can also aid in grouping different stores into separate connections within the DevTools.

The devtools function accepts the store function as its initial argument. Additionally, you can specify a name for the store or configure serialize options using a second argument.

Name store: devtools(store, {name: "MyStore"}), which will create a separate instance named "MyStore" in the devtools.

Serialize options: devtools(store, { serialize: { options: true } }).

Logging Actions

DevTools will only log actions from each separate store, as opposed to a typical combined reducers Redux store where all actions are logged.

You can log a specific action type for each set function by passing a third parameter:

const createSharkSlice = (set, get) => ({
  eatFish: () => set((prev) => ({ fishes: prev.fishes > 1 ? prev.fishes - 1 : 0 }), false, "shark/eatFish"),
});

You can also log the action's type along with its payload:

const createSharkSlice = (set, get) => ({
  addFishes: (count) =>
    set((prev) => ({ fishes: prev.fishes + count }), false, {
      type: "shark/addFishes",
      count,
    }),
});

If an action type is not specified, it will default to "anonymous". However, you can customize this default value by providing an anonymousActionType parameter:

devtools(..., { anonymousActionType: 'unknown', ... });

If you want to disable DevTools, perhaps in production, you can adjust this setting by providing the enabled parameter:

devtools(..., { enabled: false, ... });

React context

The store that is created using create does not necessitate context providers. However, there may be instances when you would like to use contexts for dependency injection, or if you want to initialize your store with props from a component. Because the standard store is a hook, passing it as a regular context value may violate the rules of hooks.

The recommended method to use the vanilla store.

import { createContext, useContext } from "react";
import { createStore, useStore } from "flowbyte";

const store = createStore(...); // vanilla store without hooks

const StoreContext = createContext();

const App = () => (
  <StoreContext.Provider value={store}>
    ...
  </StoreContext.Provider>
)

const Component = () => {
  const store = useContext(StoreContext);
  const slice = useStore(store, selector);
  ...

TypeScript Usage

Basic typescript usage doesn't require anything special except for writing create<State>()(...) instead of create(...)...

import { create } from "flowbyte";
import { devtools, persist } from "flowbyte/middleware";

interface SharkState {
  sharks: number;
  increase: (by: number) => void;
}

const useSharkStore = create<SharkState>()(
  devtools(
    persist(
      (set) => ({
        sharks: 0,
        increase: (by) => set((state) => ({ sharks: state.sharks + by })),
      }),
      {
        name: "shark-storage",
      }
    )
  )
);