Coder Social home page Coder Social logo

tiny-atom's Introduction


tiny-atom logo

๐Ÿ„ global store for sharing application state easily
โšก๏ธ highly optimised subscriptions and selectors
โš›๏ธ supports React and Preact

History

Created in 2017, Tiny Atom aimed to offer:

  • A streamlined and user-friendly alternative to Redux
  • Compatibility with both React and Preact
  • Efficient re-rendering.

Tiny Atom achieved enhanced efficiency through:

  • Tracking and deduping nested subscription updates
  • Re-rendering only when there's a change in the computed state selection.

However, with the introduction of useSyncExternalStore, React now inherently encompasses the first two aforementioned optimizations. At the same time the global state approach has been slowly falling out of fashion in favor of using higher level state abstractions like Suspense, Relay, React Query and most recently React Server Components as well as more atomic and modular strategies such as Recoil.

Although tiny-atom remains a robust and battle tested solution, it's worth considering contemporary alternatives. For instance, Zustand embodies similar global store concepts in a more modern package. Meanwhile, Kinfolk is a spiritual successor to tiny-atom offering more streamlined and powerful alternative for managing shared application state.

Installation

npm install tiny-atom

Example

import React, { useState } from 'react'
import { createAtom, Provider, useSelector, useActions } from 'tiny-atom'

const atom = createAtom({
  state: {
    user: null,
    err: null,
  },

  actions: {
    // perform async work in the actions!
    async auth({ get, set, actions }) {
      // read and write state multiple times
      // within a single action invocation!
      if (get().user) return

      const token = localStorage.getItem('access-token')
      const { user } = await fetch('/authenticate', { token })
        .then((res) => res.json())
        .catch((err) => set({ err }))

      // update atom or dispatch other actions!
      set({ user })
      actions.log()
    },
    log({ get }) {
      console.log(get().user.name)
    },
  },
})

export default function App() {
  return (
    <Provider atom={atom}>
      <Dashboard />
    </Provider>
  )
}

function Dashboard() {
  // actions are functions, no string constants!
  const { auth } = useActions()

  // subscribe to slices of state, derive state
  // only re-renders if the keys of the selected
  // object differ!
  const { user, err } = useSelector((state) => {
    return { user: state.user, err: state.err }
  })

  useEffect(() => {
    auth()
  }, [])

  if (!user) return <div>Loading</div>
  if (err) return <div>Yikes</div>
  return <div>Hello, {user.name}</div>
}

API

createAtom(options)

Create an atom. Options:

{
  state?: any;
  actions?: { [actionName: string]: ActionFunction };
  evolve?: (atom: Atom, action: any, actions: { [actionName: string]: ActionFunction }) => void;
  bindActions?: (dispatch: DispatchFunction, actions: { [actionName: string]: ActionFunction }) => void;
  debug?: DebugFunction;
}

state

The initial state of the atom. Default: {}.

actions

An object with action functions. Where every action is (params, payload) => void with params:

  • get() - get the current state
  • set(patch) - updates the state with the patch object by merging the patch using Object.assign
  • swap(state) - replace the entire state with the provided one
  • dispatch(type, payload) - same as atom.dispatch, dispatches an action
  • actions - actions prebound to dispatch, i.e. actions.increment(1) is equivalent to dispatch('increment', 1)

evolve

(atom, action, actions) => void

A reducer function that receives every dispatched action payload and calls the appropriate action function. The default implementation uses action.type to find the matching function in the actions object. Think of it as a place of setting up middleware or customising how actions get dispatched in the specific tiny-atom instance.

bindActions

(dispatch, actions) => void;

A complementary function to evolve that binds the provided actions to atom.dispatch. Together with evolve it allows customising how actions get dispatched in the specific tiny-atom instance.

debug

A function that will be called on each action and state update. The function is passed an info object of shape { type, atom, action, sourceActions, prevState }. Tiny-atom comes with 2 built in debug functions tiny-atom/log and tiny-atom/devtools.

atom.get()

Get current state.

atom.get()
atom.get().feed.items

atom.set(patch)

Update state by shallowly merging an update.

atom.set({ user })
atom.set({ entities: { ...get().entities, posts } })

atom.swap(state)

Replace the entire state with the provided value.

atom.swap(nextState)

atom.dispatch(type, payload)

Send an action

atom.dispatch('fetchMovies')
atom.dispatch('increment', 5)

atom.actions

A map of actions that are bound to the dispatch. For example, if your actions passed to atom are

const actions = {
  increment({ get, set }) {
    const { count } = get()
    set({ count: count + 1 })
  },
}

They will be bound such that calling atom.actions.increment(1) dispatches action with dispatch('increment', 1).

atom.observe(cb)

Register a callback that triggers when the atom changes. The function returns an 'unobserve' function to unregister the callback

const unobserve = atom.observe(render)
atom.observe((atom) => render(atom.get(), atom.actions))

atom.fuse({ state, actions })

Extend atom's state and the action object. Used for creating a combined atom from multiple slices of state and actions from several modules.

const state = {
  project: { name: 'tiny-atom' },
}

const actions = {
  star: ({ get, set }) => {
    set({ project: { starred: true } })
  },
}

// add extra state and actions to an existing atom instance
atom.fuse(state, actions)

React API

<Provider atom={atom} />

Provide atom instance created using createAtom as context to the render tree.

useAtom()

Get atom instance provided by the Provider. Note: typically you should prefer using useSelector and useActions instead of using atom directly.

useSelector(selectorFn, options)

Select a slice of state and subscribed to state changes. Full state is passed to selectorFn where any computed value can be returned. The component will only re-render if the computed value differs by shallowly comparing every key of the previous and updated computed object.

Options:

observe

type: boolean default: true in the browser, false on the server

Use this to control if the hook should subscribe to the store and re-renders on every change or simply projects the state on parent re-renders, but does not re-render on state changes.

useActions()

Get the bound actions.

const { increment, decrement } = useActions()

useDispatch()

Get the dispatch function.

const dispatch = useDispatch()
dispatch('increment')
dispatch('increment', { by: 5 })

createContext()

By default, useSelector and useActions are bound to the default tiny-atom context that is shared across the app. Use this to create an isolated context. Note: you will need to create a dedicated set of hooks using createHooks(AtomContext).

const { AtomContext, Provider } = createContext()

createHooks(AtomContext)

Create custom set of hooks tailored for a specific context.

const { AtomContext, Provider } = createContext()
const { useSelector, useActions } = createHooks(AtomContext)

React HOC API

Before the widespread use of React hooks, Tiny Atom utilized the Higher Order Component (HOC) pattern. The following functions enable the use of tiny-atom with this pattern.

connect

const map = (state) => ({ count: state.count })
const ConnectedComponent = connect(map, options)(Component)

Connects a component to atom and re-renders it upon relevant changes. Connected component will be passed an action props with the atom's actions and the mapped props (the props returned in the map function).

map

type: function default: null

Map atom state to props for your component. Upon changes to atom, the mapped props are compared to the previously mapped props and the connected component is only re-rendered if they differ. A shallow object diff is used in the comparison.

options.observe

type: boolean default: true in the browser, false on the server

Use this to control if the connector subscribes to the store or simply projects the state on parent re-renders.

<Consumer map={map} />

A render props style component that can be used inline of your component's render function to map the state similarly to how connect works. It supports the following props.

map

type: function default: null

Map atom state to props for your component. Upon changes to atom, the mapped props are compared to the previously mapped props and the connected component is only re-rendered if they differ. A shallow object diff is used in the comparison.

observe

type: boolean default: true in the browser, false on the server

Use this to control if the connector subscribes to the store or simply projects the state on parent re-renders.

createConnect(AtomContext)

Create an isolated connect bound to a specific AtomContext.

createConsumer(AtomContext)

Create an isolated <Consumer /> bound to a specific AtomContext.

Preact API

For Preact, import createAtom, Provider, Consumer and connect from tiny-atom/preact. The hooks based API for Preact is not currently available.

tiny-atom's People

Contributors

alanclarke avatar kidkarolis avatar kwanman avatar littleninja avatar samuelfullerthomas avatar tjenkinson avatar tylerbutler avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

tiny-atom's Issues

Pass `atom` as 3rd arg to ConnectAtom map

This way, you can attach extra stuff to atom, such as actions and pass those around!
Actually, consider converting it from positional args to {state, split, atom}, this way you can more easily only use the argument you need. On the other hand, destructuring is harder, consider ({count}, split, {actions}) vs ({ state: { count }, atom: { actions }}).

Consider passing bound actions to each action

It's awkward that we have to use 2 different ways of dispatching actions, actions.bla() in components and dispatch('bla') in actions. Unify it by using bound actions everywhere.

Explore reactions idea

In addition to actions, sometimes it's useful to react to state change stream, a bit like state machine, if the route is that and user is guest and this condition is that, trigger X, then trigger Y, etc.

Or say, if user is not guest, subscribe to feathers service changes, once component is unmounted or user is guest, unsubscribe, etc.

This could be done with some sort of reaction mechanism that can be invoked as a hook, e.g.

// called on each state change
useReaction((state) => {
  return () => {
     // cleanup
  }
}, [deps])

tbh, it looks just like useEffect (which it is), it's just the angle of it being a state stream that's appealing, but maybe this is more of a pattern that could be easily used without atom being involved.

Consider using unstable_batchedUpdates instead of defering subscribers

As discussed in context of: facebook/react#14110 (comment).

This probably would mean relinquishing ability to set some state sync, because we'd have to move requestAnimationFrame from the point of subscribing to the point of publishing. By batching and wrapping every store change event in requestAnimationFrame+unstable_batchedUpdates, we'd not have to worry about subscription order and would in general have to do less heavy lifting at the point of subscription. But that means there would be no way to subscribe sync. Or maybe there would be by expressing whether your subscription is part of batch or should be individual. I remember thinking along those lines before, but I don't remember why I didn't go for it.

Typescript typings

Hi, I wrote some (WIP) typescript typings which can properly handle tiny-atom with react hook bindings:

declare module 'tiny-atom' {

    /** Type to create object like { foo: never, bar: "bar" } if bar matches condition and foo not */
    type FilterKeys<Base, Condition> = { [Key in keyof Base]: Base[Key] extends Condition ? Key : never };

    /** Return type containing distinction of all values of T */
    type Values<T> = T[keyof T];

    export interface DispatchFunction<A = any> {
        <AA extends ActionsWithoutPayload<A>>(action: AA): void;
        <AA extends ActionsWithPayload<A>>(action: AA, payload: ActionHandlerPayload<A[AA]>): void;
    }

    export type ObserveFunction<ATOM> = (atom: ATOM) => void;

    export type UnobserveFunction = () => void;

    export type AtomState<ATOM> = ATOM extends Atom<infer S, any> ? S : never;

    export type AtomActions<ATOM> = ATOM extends Atom<any, infer A> ? A : never;

    export type ActionHandlerPayload<F> =
        F extends ActionHandlerWithoutPayload<any> ? undefined :
        F extends ActionHandlerWithPayload<any, infer P> ? P :
        never;

    export type AtomBoundAction<F> =
        F extends ActionHandler<any, undefined> ? () => void :
        F extends ActionHandler<any, infer P> ? (payload: P) => void :
        never;

    export type ActionsWithoutPayload<A> = Values<FilterKeys<A, ActionHandlerWithoutPayload>>;
    export type ActionsWithPayload<A> = Values<FilterKeys<A, ActionHandlerWithPayload>>;

    export type AtomBoundActions<ATOM> = {
        [key in keyof AtomActions<ATOM>]: AtomBoundAction<AtomActions<ATOM>[key]>;
    };

    export interface Atom<S = any, A = any> {
        dispatch: DispatchFunction;
        fuse: <S2, A2>(state: S2, actions: A2) => Atom<S & S2, A & A2>;
        get: () => S;
        observe: (cb: ObserveFunction<this>) => UnobserveFunction;
        set: (partial: Partial<S>) => void;
        swap: (state: S) => void;
    }

    export interface ActionAtom<S> {
        dispatch: DispatchFunction;
        get: () => S;
        set: (partial: Partial<S>) => void;
        swap: (state: S) => void;
    }

    type ActionHandlerWithoutPayload<S = any> = (store: ActionAtom<S>) => any;
    type ActionHandlerWithPayload<S = any, P = any> = (store: ActionAtom<S>, payload: P) => void;
    export type ActionHandler<S = any, P = any> = ActionHandlerWithPayload<S, P> | ActionHandlerWithoutPayload<S>;

    export default function createAtom<S = {}, A = {}>(initialState: S, actions: A): Atom<S, A>;
}

declare module 'tiny-atom/react' {
    import React from 'react';
    import { Atom } from 'tiny-atom';

    export const Provider: React.SFC<{ atom: Atom }>;
    export const Consumer: React.Consumer<Atom>;
    export function connect(...args: any[]): any;
}

declare module 'tiny-atom/react/hooks' {
    import {
        Atom,
        AtomState,
        AtomActions,
        AtomBoundActions,
        DispatchFunction,
    } from 'tiny-atom';

    export interface UseAtomOptions {
        /**
         * If the connection is pure, the mapped props are compared to previously mapped props for avoiding rerenders. 
         * Set this to false to rerender on any state change.
         * default: true
         */
        pure?: boolean;
        /**
         * By default, the change listeners are debounced such that at most one render occurs per frame. 
         * Set to true to rerender immediately on change state.
         * default: false
         */
        sync?: boolean;
        /**
         * Use this to control if the connector subscribes to the store or simply projects the state on parent rerenders.
         * default: true in the browser, false on the server
         */
        observe?: boolean;
    }

    // export function useAtom<ATOM extends Atom, MAP extends (state: AtomState<ATOM>) => any = (state: AtomState<ATOM>) => any>(map?: MAP, options?: UseAtomOptions): ReturnType<MAP>;
    // export function useAtom<MAP extends (state: any) => any>(map?: MAP, options?: UseAtomOptions): ReturnType<MAP>;
    export function useAtom<S, R>(map?: (state: S) => R, options?: UseAtomOptions): R;
    export function useActions<ATOM extends Atom>(): AtomBoundActions<ATOM>;
    export function useDispatch<ATOM extends Atom>(): DispatchFunction<AtomActions<ATOM>>;
}

Here's an example of them being used:

import React from 'react';
import createAtom, { ActionAtom, AtomState } from 'tiny-atom';
import { Provider } from 'tiny-atom/react';
import { useAtom, useActions, useDispatch } from 'tiny-atom/react/hooks';

interface State {
    count: number;
}

const initialState: State = {
    count: 0,
};

const atomActions = {
    increment: ({ get, set }: ActionAtom<State>, n: number) => {
        const count = get().count;
        set({ count: count + n });
    },
    decrement: ({ get, set }: ActionAtom<State>, n: number) => {
        const count = get().count;
        set({ count: count - n });
    },
    /** payload-less action */
    incrementOne: ({ get, set }: ActionAtom<State>) => {
        set({ count: get().count + 1 });
    },
    log: ({ }: ActionAtom<State>, msg: string) => {
        console.log(msg);
    },
};

const atom = createAtom(initialState, atomActions);

/** utility function to simplyfy useAtom calls */
function useAtomTyped<ATOM>() {
    return <MAP extends (state: AtomState<ATOM>) => any>(map?: MAP) =>
        useAtom<AtomState<ATOM>, ReturnType<MAP>>(map);
}

const TestTinyAtom = () => {
    // const state = useAtom((s: AtomState<typeof atom>) => s.count);
    const state = useAtomTyped<typeof atom>()(s => s.count);
    const actions = useActions<typeof atom>();
    const dispatch = useDispatch<typeof atom>();
    // even correctly handles dispatch ('incrementOne') needing no args and dispatch ('increment', 1) requiring a number argument
    // so for example dispatch('increment') or dispatch('decrement', 'test') will correctly throw build errors! ;-)
    return (
        <div>
            <p>Counter = {state}</p>
            <button onClick={() => actions.incrementOne()}>+</button>
            <button onClick={() => actions.decrement(1)}>-</button>
            <button onClick={() => dispatch('decrement', 2)}>--</button>
        </div>
    );
};

export default () => (
    <Provider atom={atom}>
        <TestTinyAtom />
    </Provider>
);

I think it might be worth including typings in this project as it greatly improves usability.
Also it properly handles payload-less dispatch calls which was quite difficult to figure out ๐Ÿ˜‰

These are still not finished completely (connect missing and some other small stuff for createAtom), but I want to post these here for now so others looking for this library can use them already.

Rethink deepMerge, it's not a success pit

The current default deep merge behavior can lead to subtle, hard to notice issues.

When calling set({ nested: { a: 1 }}), you usually don't consider that the rest of the nested attributes remain. Often it's convenient, but sometimes it will lead to bugs.

Ideally there's a way to make this work where it's more explicit, but still convenient. Some ideas:

  • Don't deep merge, means you need to use zaphod/immer/assign or a similar approach.
  • Make deepMerge an opt in flag in set.
  • Make it convenient to set things in paths, e.g. setIn in addition to set, this probably removes the typical pain point. Possibly updateIn as well.

memoized selectors

Hello,
is this library able to do memoized selectors aka reselect? If yes how should it be used the "tiny-atom way" ?

Build in a tiny-atom/bundles

It would be an evolver you can plug in and it would basically allow importing a bunch of bundles/modules of shape:

{
  name,

  // initial state
  initialState,

  // a list of actions
  actions,

  // runs on startup
  init,

  // called on every local state change and allows deriving new states
  reactors,

  // selectors for the data, made available in the connect()
  selectors
}

Plus allow passing in custom objects, such as api or fetch etc.
Log if name, action or selector names clash.

Annotate sets() with log messages

Very useful for debugging! More useful than the diff summary I would say. Each line becomes meaningful, easier to identify issues.

image

Simplify docs

  • merge all content into a single page! easier to scan, easier to cmd+f
  • remove some superfluous sections!
  • move some content into a blog and link!

Fix the ordering introduced in 3.2.0 wrt to componentDidMount ordering

componentDidMount is called inside out, so the current strategy for pushing in the listeners doesn't work well enough when nested connected components are injected into the DOM later on. In react-redux they create a subscriber in the constructor and then subscribe in componentDidMount against the parent subscriber to maintain a tree of subscriptions.

Remove mutable set

Right now we have this "feature" where you can mutate the state directly and set it as is by reference:

state.count = 1
set(state)

Which is there for performance or whatevs. But maybe it just needlessly complicates the API surface and code and docs:

atom.split(state*) - if you mutate state and pass the same reference to split, it will record the new state without Object.assign and will trigger a render.

I suggest we remove this "feature". You can still achieve something fairly similar with:

let counters = get().counters
counters.foo = 1 // mutate
set({ counters })

Or you could actually just change the extend (4th constructor arg) to not do Object.assign on an empty object, but merge the change into the state, that way there is no GC?

createAtom({ count: 1 }, evolve, render, update)
function update (empty, prev, next) => Object.assign(prev, next)

react connectors don't work on react-native

getDerivedStateFromProps behaves differently in react and react-native: facebook/react-native#20759

In react-native, this is only called when props update, not when state updates. This means if you have a state change that does not cause the parent of a connected component to pass down new props, we don't derived the new mapped props.

Set {} instead of real mapped props in the local state

The only reason we're using setMappedProps is to trigger a rerender upon observing a change in atom. And we also skip setting it sometimes to avoid rerendering needlessly in case parent already rendered us. However, this can cause a bug where the last time we called setMappedProps with the same value as calling it now, this would mean the component doesn't get rerendered.

Anyway, tl;dr is to setMappedProps({}) with unique object each time to guarantee rerender, since that's what we're using this for.

A potentially better longer term alternative still remains the unstable_batchedUpdate, where we could perhaps solve all of the ordering, parent rerenderings and batching observation/parent renders. For now, the {} fix should do the trick though.

Controlled input issue

If you use raf(render) and store input value in tiny-atom, typing in the middle of the input text causes the cursor to jump to the end if you type very fast (inbetween frames). Interesting issue.

Race condition in render and commit phases leads to missed state updates

Potential fix is to call onChange function manually in useEffect() to account for any atom changes in between it being rendered and being commited to DOM (and effect getting called).

// trigger onChange in case atom changed in between us
// rendering the consuming component and subscribing to the store
onChange()

5.0.0 checklist

I think it's time (again) to introduce some breaking changes.

  1. Update signature to createAtom({ state, actions, other, options }).
  2. Update fuse signature to fuse({ state, actions }).
  3. Update <Consumer /> and connect to work with bound actions instead of dispatch and strings.
  4. Make dispatch secondary everywhere, in theory it could be removed altogether and kept as a action bind time concern. But since it's still quite central to the way tiny atom works, I will leave it, but push it a bit to the background. So you could still use it everywhere, but won't have to ever touch it by default.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.