flowbyte
v1.0.0
Published
Simplify State, Amplify Productivity
Downloads
84
Maintainers
Readme
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",
}
)
)
);