GithubHelp home page GithubHelp logo

derrickbeining / react-atom Goto Github PK

View Code? Open in Web Editor NEW
158.0 4.0 7.0 4.26 MB

A simple way manage state in React, inspired by Clojure(Script) and reagent.cljs

Home Page: https://derrickbeining.github.io/react-atom

License: MIT License

JavaScript 10.67% TypeScript 88.44% Shell 0.89%
react reactjs react-native state-management hooks-api-react hooks redux reagent cljs clojurescript

react-atom's Introduction

react-atom logo

A simple way to manage shared state in React

Built on the React Hooks API

Inspired by atoms in reagent.cljs

TypeScript npm (scoped) npm bundle size (minified) npm bundle size (minified + gzip)

Build Status codecov npm

NpmLicense Commitizen friendly semantic-release

Description

react-atom provides a very simple way to manage state in React, for both global app state and for local component state: ✨Atoms✨.

Put your state in an Atom:

import { Atom } from "@dbeining/react-atom";

const appState = Atom.of({
  color: "blue",
  userId: 1
});

Read state with deref

You can't inspect Atom state directly, you have to dereference it, like this:

import { deref } from "@dbeining/react-atom";

const { color } = deref(appState);

Update state with swap

You can't modify an Atom directly. The main way to update state is with swap. Here's its call signature:

function swap<S>(atom: Atom<S>, updateFn: (state: S) => S): void;

updateFn is applied to atom's state and the return value is set as atom's new state. There are just two simple rules for updateFn:

  1. it must return a value of the same type/interface as the previous state
  2. it must not mutate the previous state

To illustrate, here is how we might update appState's color:

import { swap } from "@dbeining/react-atom";

const setColor = color =>
  swap(appState, state => ({
    ...state,
    color: color
  }));

Take notice that our updateFn is spreading the old state onto a new object before overriding color. This is an easy way to obey the rules of updateFn.

Side-Effects? Just use swap

You don't need to do anything special for managing side-effects. Just write your IO-related logic as per usual, and call swap when you've got what you need. For example:

const saveColor = async color => {
  const { userId } = deref(appState);
  const theme = await post(`/api/user/${userId}/theme`, { color });
  swap(appState, state => ({ ...state, color: theme.color }));
};

Re-render components on state change with the ✨useAtom✨ custom React hook

useAtom is a custom React Hook. It does two things:

  1. returns the current state of an atom (like deref), and
  2. subscribes your component to the atom so that it re-renders every time its state changes

It looks like this:

export function ColorReporter(props) {
  const { color, userId } = useAtom(appState);

  return (
    <div>
      <p>
        User {userId} has selected {color}
      </p>
      {/* `useAtom` hook will trigger a re-render on `swap` */}
      <button onClick={() => swap(appState, setRandomColor)}>Change Color</button>
    </div>
  );
}

Nota Bene: You can also use a selector to subscribe to computed state by using the options.select argument. Read the docs for details.

Why use react-atom?

😌 Tiny API / learning curve
`Atom.of`, `useAtom`, and `swap` will cover the vast majority of use cases.
🚫 No boilerplate, just predictable state management
Reducers? Actions? Thunks? Sagas? Nope, just `swap(atom, state => newState)`.
🎵 Tuned for performant component rendering
The useAtom hook accepts an optional select function that lets components subscribe to computed state. That means the component will only re-render when the value returned from select changes.
😬 React.useState doesn't play nice with React.memo
useState is cool until you realize that in most cases it forces you to pass new function instances through props on every render because you usually need to wrap the setState function in another function. That makes it hard to take advantage of React.memo. For example:
---
function Awkwardddd(props) {
  const [name, setName] = useState("");
  const [bigState, setBigState] = useState({ ...useYourImagination });

  const updateName = evt => setName(evt.target.value);
  const handleDidComplete = val => setBigState({ ...bigState, inner: val });

  return (
    <>
      <input type="text" value={name} onChange={updateName} />
      <ExpensiveButMemoized data={bigState} onComplete={handleDidComplete} />
    </>
  );
}

Every time input fires onChange, ExpensiveButMemoized has to re-render because handleDidComplete is not strictly equal (===) to the last instance passed down.

The React docs admit this is awkward and suggest using Context to work around it, because the alternative is super convoluted.

With react-atom, this problem doesn't even exist. You can define your update functions outside the component so they are referentially stable across renders.

const state = Atom.of({ name, bigState: { ...useYourImagination } });

const updateName = ({ target }) => swap(state, prev => ({ ...prev, name: target.value }));

const handleDidComplete = val =>
  swap(state, prev => ({
    ...prev,
    bigState: { ...prev.bigState, inner: val }
  }));

function SoSmoooooth(props) {
  const { name, bigState } = useAtom(state);

  return (
    <>
      <input type="text" value={name} onChange={updateName} />
      <ExpensiveButMemoized data={bigState} onComplete={handleDidComplete} />
    </>
  );
}
TS First-class TypeScript support
react-atom is written in TypeScript so that every release is published with correct, high quality typings.
👣 Tiny footprint
react-atom minified file size react-atom minified+gzipped file size
⚛️ Embraces React's future with Hooks
Hooks will make class components and their kind (higher-order components, render-prop components, and function-as-child components) obsolete. react-atom makes it easy to manage shared state with just function components and hooks.

Installation

npm i -S @dbeining/react-atom

Dependencies

react-atom has one bundled dependency, @libre/atom, which provides the Atom data type. It is re-exported in its entirety from @dbeining/atom. You may want to reference the docs here.

react-atom also has two peerDependencies, namely, react@^16.8.0 and react-dom@^16.8.0, which contain the Hooks API.

Documentation

react-atom API

@libre/atom API

Code Example: react-atom in action

Click for code sample
import React from "react";
import ReactDOM from "react-dom";
import { Atom, useAtom, swap } from "@dbeining/react-atom";

//------------------------ APP STATE ------------------------------//

const stateAtom = Atom.of({
  count: 0,
  text: "",
  data: {
    // ...just imagine
  }
});

//------------------------ EFFECTS ------------------------------//

const increment = () =>
  swap(stateAtom, state => ({
    ...state,
    count: state.count + 1
  }));

const decrement = () =>
  swap(stateAtom, state => ({
    ...state,
    count: state.count - 1
  }));

const updateText = evt =>
  swap(stateAtom, state => ({
    ...state,
    text: evt.target.value
  }));

const loadSomething = () =>
  fetch("https://jsonplaceholder.typicode.com/todos/1")
    .then(res => res.json())
    .then(data => swap(stateAtom, state => ({ ...state, data })))
    .catch(console.error);

//------------------------ COMPONENT ------------------------------//

export const App = () => {
  const { count, data, text } = useAtom(stateAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Text: {text}</p>

      <button onClick={increment}>Moar</button>
      <button onClick={decrement}>Less</button>
      <button onClick={loadSomething}>Load Data</button>
      <input type="text" onChange={updateText} value={text} />

      <p>{JSON.stringify(data, null, "  ")}</p>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

🕹️ Play with react-atom in CodeSandbox 🎮️

You can play with react-atom live right away with no setup at the following links:

JavaScript Sandbox TypeScript Sandbox
try react-atom try react-atom

Contributing / Feedback

Please open an issue if you have any questions, suggestions for improvements/features, or want to submit a PR for a bug-fix (please include tests if applicable).

react-atom's People

Contributors

dependabot-preview[bot] avatar derrickbeining 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

react-atom's Issues

Bootstrapping an Atom from localStorage... how to?

I'm new to React and this library, so my question may be a bit silly.

I'm using React hooks like useState, useReducer and dispatch inside components and using custom helper functions. For a small app like mine, this is overkill.

I need to persist the state into the local storage, but I don't know to do this using this library. Using React state management, I would use a custom hook like this:

import React from 'react';

/**
 * @param {string} storageKey
 * @param {*} initialState
 * @param {number} expiration
 */
export const useStateWithLocalStorage = (storageKey, initialState, expiration) => {
  const [value, setValue] = React.useState(() => {
    const value = localStorage.getItem(storageKey);
    if (null === value || 'undefined' === typeof value) {
      return initialState;
    }

    const expiration = parseInt(localStorage.getItem(`${storageKey}_expire`));
    if (!isNaN(expiration) && Math.floor(Date.now() / 1000) > expiration) {
      localStorage.removeItem(`${storageKey}_expire`);

      return initialState;
    }

    if ('{' === value[0] || '[' === value[0]) {
      return JSON.parse(value);
    }

    return value;
  });

  React.useEffect(() => {
    localStorage.setItem(storageKey, null !== value && 'object' === typeof value ? JSON.stringify(value) : value);

    if (expiration > 0) {
      const existingExpiration = localStorage.getItem(`${storageKey}_expire`);
      if (null === existingExpiration || 'undefined' === typeof existingExpiration) {
        localStorage.setItem(`${storageKey}_expire`, Math.floor(Date.now() / 1000) + expiration);
      }
    }
  }, [storageKey, value, expiration]);

  return [value, setValue];
};

HOC for use with non hook components

Having a code base that has both hook based and class based components is common. It would be useful for react-atom to provide a HOC for these components. Something simple like the following should work.

const connect = (mapStateToProps, store) => Component => props => {
  const stateProps = mapStateToProps(useAtom(store))
  return <Component {...stateProps} {...props} />
}

optimize `swap` to run selectors prior to useState hooks

swap currently loops through useAtom subscriptions once and, for each subscription, runs both the selector (if present) and the useState hook. I anticipate this being an issue (no data on this), because selectors are potentially expensive computations which could block between useState hooks firing to re-render components. That would probably make the component rendering "janky". Instead, selectors should be run before useState hooks so that the re-render cycle looks seamless.

Getting no TypeScript error in swap

Hi!

Thank you for maintaining such a good lib! I wondered if somebody implemented something like an atom in Clojure and I'm here!

UPD:
Probably I should address this question to @libre/atom, but I'll leave it here for now.

I'm playing with it and thinking about using it in my TypeScript project but I'm curious why I'm not receiving any TS error when trying to swap state to something, that doesn't match its initial shape.
For example:

export interface AppState {
    isLoading: Boolean
}

export const appState = Atom.of<AppState>({
    isLoading: false,
})

export const setIsLoading = (isLoading: boolean) =>
    swap<AppState>(appState, (state) => ({
        ...state,
        foo: 0, // No error here
        isLoading,
    }))

Am I doing something wrong?

Thank you in advance!

useAtom Destructuring

Hi,

is there a reason why const { a, b, c } = useAtom(stateAtom) doesn't use the destructed keys as default options.select filter?

I've build a little helper function doing that but I am unsure if this makes sense at all?! Right now it doesn't guard against any invalid keys at all and is more a proof of concept :

const useAtomProxy = (f) => (stateAtom) =>
  new Proxy(
    {},
    {
      get: (_, name) =>
        f(stateAtom, {
          select: (s) => s[name],
        }),
    }
  );
const useAtomSelected = useAtomProxy(useAtom);

...

const { a, b, c } = useAtomSelected(stateAtom);

Double renders

It looks like the useAtom() hook produces a double rendering of the component. I have an example running here on codesandbox ... inspect the console output and you see for every update of the stateAtom that the console.log outputs twice in a row.

optimize `select` option in `useState`

Currently, a given selector is run in both swap (to check if selected state did change) and useAtom, which is redundant work. Need to find a way to consolidate these.

make `swap` automatically merge return value of `updateFn` with `state`

It would be nice not to have to spread state all over the place in swap, like this:

swap(atom, (state) => ({
  ...state,
  stuff: {
    ...state.stuff,
    things: [...state.stuff.things, newThing]
  }
})

and instead be able to do this:

swap(atom, (state) => ({
  stuff: {
    things: [...state.stuff.things, newThing]
  }
})

swap would still work if you manually spread things like now, and we could avoid unnecessary deep merging by just checking Object.is between the old and new state and just skip merging those that are equal.

How to use `Atom` in component state with context ?

This is a neat library, and I want to try it. But here is one thing i haven't figure it out. If effects takes Atom argument, a bunch of effects need to be wrapped in useCallback like memoIncrement below.

import * as React from "react";
import { useAtom, Atom, swap } from "@dbeining/react-atom";


export type AppState = Atom<{
  count: number;
  text: string;
  data: any;
}>;


// effects
export const increment = (stateAtom: AppState) =>
  swap(stateAtom, state => ({ ...state, count: state.count + 1 }));

export const decrement = (stateAtom: AppState) =>
  swap(stateAtom, state => ({
    ...state,
    count: state.count && state.count - 1
  }));

export const updateText = (
  stateAtom: AppState,
  evt: React.ChangeEvent<HTMLInputElement>
): void => swap(stateAtom, state => ({ ...state, text: evt.target.value }));

export const loadSomething = (stateAtom: AppState) =>
  fetch("https://jsonplaceholder.typicode.com/todos/1")
    .then(res => res.json())
    .then(data => swap(stateAtom, state => ({ ...state, data })))
    .catch(console.error);

const AppContext = React.createContext<AppState | undefined>(undefined);

export const App = () => {
  const stateAtom = React.useRef(
    Atom.of({
      count: 0,
      text: "",
      data: {}
    })
  );

  console.log("App render()");

  return (
    <AppContext.Provider value={stateAtom.current}>
      <Child />
    </AppContext.Provider>
  );
};

export function Child() {
  const stateAtom = React.useContext(AppContext);
  const { count, data, text } = useAtom(stateAtom);

  const memoIncrement = React.useCallback(
    ev => {
      increment(stateAtom);
    },
    [stateAtom]
  );

  return (
    <div>
      <h2>Count: {count}</h2>
      <h2>Text: {text}</h2>

      <button onClick={memoIncrement}>Moar</button>
      <button onClick={ev => decrement(stateAtom)}>Less</button>
      <button onClick={ev => loadSomething(stateAtom)}>Load Data</button>
      <input
        type="text"
        onChange={ev => updateText(stateAtom, ev)}
        value={text}
      />
      <p>{JSON.stringify(data, null, "  ")}</p>
    </div>
  );
}

Introducing the useAtomState hook

I am using react-atom in multiple projects, often starting with React.useState() then upgrading to atoms. The useAtomState() hook below makes it take only a few keystrokes to replace useState with a react-atom. It has the same return signature as useState but takes an Atom.

Perhaps it could be added to the distribution? It would make it (even) easier for React devs to start using react-atom and upgrade their existing react hook codebase.

function useAtomState (atom) {
  const state = useAtom(atom);
  const setState = useCallback ((obj) => {
    swap(atom, (typeof obj === 'function') ? obj : () => obj)
  }, [atom]);
  return ([state, atom ? setState : null]);
}

Your .dependabot/config.yml contained invalid details

Dependabot encountered the following error when parsing your .dependabot/config.yml:

Automerging is not enabled for this account. You can enable it from the [account settings](https://app.dependabot.com/accounts/derrickbeining/settings) screen in your Dependabot dashboard.

Please update the config file to conform with Dependabot's specification using our docs and online validator.

Subscription list is invalidated if a parent unmounts a child.

In the code below, k can be invalidated if a previous call to changeHandlersByAtomId causes a component to unmount farther down the list.

function _runChangeHandlers(atom, previous, current) {
  Object.keys(changeHandlersByAtomId[atom["$$id"]]).forEach(function (k) {
    changeHandlersByAtomId[atom["$$id"]][k]({
      previous: previous,
      current: current
    });
  });
}

Debugging State

Hi @derrickbeining - great project you got here!

I've been using React Waterflow earlier on (as a replacement for Redux et al.) and I like what you did. Actually, I like it so much, that I converted / introduced only your package for global state management in all recent projects I'm involved in. The only thing that I like more about React Waterflow is the possibility of adding the (very mature / advanced) Redux dev tools.

For me the most important / crucial part about the Redux dev tools was the console logging during development / non-production runtime. So I thought "why not try to re-create that experience". All in all I did not write any compatibility layer, but rather just a lightweight console output.

The code is:

addChangeHandler(globalState, 'debugging', ({ current, previous }) => {
    const action = new Error().stack.split('\n')[6].replace(/^\s+at\s+Atom\./, '');
    console.group(
    `%c Portal State Change %c ${new Date().toLocaleTimeString()}`,
    'color: gray; font-weight: lighter;',
    'color: black; font-weight: bold;',
    );
    console.log('%c Previous', `color: #9E9E9E; font-weight: bold`, previous);
    console.log('%c Action', `color: #03A9F4; font-weight: bold`, action);
    console.log('%c Next', `color: #4CAF50; font-weight: bold`, current);
    console.groupEnd();
});

where globalState is the created globalState. You can see the whole source code / repository here. We placed this code in a conditional to avoid placing the code in the production build.

Maybe you / someone find(s) this snippet useful and we could provide it as a utility out of the box (properly exported such that tree shaking can remove it if not being used, e.g., in a PROD build of the consuming application).

Thanks again for your nice lib - great job! 🍻

Outdated react-dom peer dependency

A project using react-atom 4.1.1 with dependency on react-dom 16.9.0 results in a warning during npm install:

@dbeining/[email protected] requires a peer of react-dom@>=16.7.0-alpha.0 || >=16.8.0-alpha.0 || ^16.8.x but none is installed. You must install peer dependencies yourself.

`useAtom` `options.select` gets mixed up when more than one atom

the selector map is having key collisions because hook ids are not absolutely unique, they're only unique to the set of hook ids associated with a given atom. It's causing the wrong selector to be used when there are multiple atoms.

Currently the selector map is like this:
{ [hookId: number]: Selector }

But needs to be like this:
{ [atomId: number]: { [hookId: number]: Selector } }

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.