Coder Social home page Coder Social logo

react-recollect's Introduction

version Tests

React Recollect

What?

Recollect is a state management library for React, an alternative to Redux.

Why?

Recollect aims to solve two problems with the traditional React/Redux approach:

  1. Immutability logic is verbose, complicated, and prone to bugs.
  2. Developers must write code to define which parts of the store a component plans to use. Even then, components can be re-rendered when they don't use the data that has just changed.

How?

  1. The Recollect store is immutable, but the implementation is hidden. So, you can interact with the store as though it were a plain JavaScript object.
  2. Recollect records access to the store during the render cycle of a component. When a property in your store changes, only components that use that property are re-rendered.

The result is simpler code and a faster app. Take it for a spin in this CodeSandbox.


Caution: there is no support for any version of IE, Opera mini, or Android browser 4.4 (because Recollect uses the Proxy object). Check out the latest usage stats for proxies at caniuse.com.

Quick start

npm i react-recollect

The store object and the collect function are all you need to know to get started.

The store is where your data goes; you can treat it just like you'd treat any JavaScript object. You can import, read from, and write to the store in any file.

Here's some code doing normal things with the normal-looking store:

import { store } from 'react-recollect';

store.tasks = ['one', 'two', 'three']; // Fine

store.tasks.push('four'); // Good

store.site = { title: 'Page one' }; // Acceptable

Object.assign(store.site, { title: 'Page two' }); // Neato

store.site.title += '!'; // Exciting!

delete store.site; // Seems extreme, but works a treat

store = 'foo'; // Nope! (can't reassign a constant)

Play with this code in a CodeSandbox

These operations behave just like you'd expect them to, except none of them mutate the store contents. In fact, it's impossible to mutate the data in a Recollect store.

Next up: the collect function. This wraps a React component, allowing Recollect to take care of it. This will provide the store as a prop, and update the component when it needs updating.

Here's collect and store working together:

import { collect } from 'react-recollect';

const TaskList = ({ store }) => (
  <div>
    {store.tasks.map((task) => (
      <div>{task.name}</div>
    ))}

    <button
      onClick={() => {
        store.tasks.push({
          name: 'A new task',
          done: false,
        });
      }}
    >
      Add a task
    </button>
  </div>
);

export default collect(TaskList);

Congratulations my friend, you've finished learning Recollect. I am very proud of you.

Go have a play, and when you're ready for more readme, come back to read on.

If you've got a question, make sure to read the FAQ to see if your Q is FA. Otherwise, open a GitHub issue.


Installation

NPM

Install with npm:

npm install react-recollect

Or Yarn:

yarn add react-recollect

You can then import it in the usual ways:

import { collect, store } from 'react-recollect';

// or
const { collect, store } = require('react-recollect');

CDN

You can also load Recollect from the unpkg CDN.

<script src="https://unpkg.com/react-recollect"></script>

This will create a global ReactRecollect object. See demo/public/browser.html for a working example with React and Babel.

It's a good idea to reference an exact version in the URL, so that it can be cached. Click here to get the full URL.

API

store

Added in 1.0.0

The store object that Recollect exposes is designed to behave like a plain old JavaScript object. But it's a bit different because it's immutable. You can write code as though you were mutating it, but internally it will clone the parts of itself that it needs to clone to apply your changes, without mutating anything.

When the store is then passed to a component, React can do its clever shallow comparisons to know whether something has changed and update efficiently.

collect(ReactComponent)

Added in 1.0.0

When you wrap a component in collect, Recollect will:

  • Provide the store object as a prop.
  • Collect information about the data the component needs to render (which properties in the store it read while rendering).
  • Re-render the component when that data changes.

Internally, Recollect 'subscribes' components to property 'paths'. For example, this component would be subscribed to the store.page.title path and re-rendered when that property changes.

import { collect } from 'react-recollect';

const Header = ({ store }) => (
  <header>
    <h1>{store.page.title}</h1>
  </header>
);

export default collect(Header);

afterChange(callback)

Added in 1.0.0, the below applies to 4.0.0 and up

afterChange will call the provided callback whenever the store updates.

The callback receives an event object with these properties:

  • store — the store
  • changedProps — the 'paths' of the properties that changed. E.g. ['tasks.2.done', 'tasks.4.done']
  • renderedComponents — an array of the components that were updated

For example, if you want to save the current page to local storage when a particular value in the store changes, you could do the following (anywhere in your app).

import { afterChange } from 'react-recollect';

afterChange((e) => {
  if (e.changedProps.includes('currentPage')) {
    localStorage.currentPage = e.store.currentPage;
  }
});

initStore(data)

Added in 2.4.0

The initStore function will replace the contents of the store with the object you pass in.

data is optional — if you don't pass anything, the store will be emptied (useful in tests).

If you're only using Recollect in the browser, you don't need to use this, but it's handy to set the default state of your store. You can also use Object.assign(store, { foo: 'bar' }) if you want to shallow-merge new data into the store.

When you render on the server though, you do need to initialize the store, because unlike a browser, a server is shared between many users and state needs to be fresh for each request.

On the server

Here's a minimal implementation of server-side rendering with Express and Recollect.

// Create an express app instance
const app = express();

// Read the HTML template on start up (this is the create-react-app output)
const htmlTemplate = fs.readFileSync(
  path.resolve(__dirname, '../../build/index.html'),
  'utf8'
);

// We'll serve our page to requests at '/'
app.get('/', async (req, res) => {
  // Fetch some data
  const tasks = await fetchTasksForUser(req.query.userId);

  // Populate the Recollect store (discarding any previous state)
  initStore({ tasks });

  // Render the app. Components will read from the Recollect store as usual
  const appMarkup = ReactDOMServer.renderToString(<App />);

  // Serialize the store (replacing left tags for security)
  const safeStoreString = JSON.stringify(store).replace(/</g, '\\u003c');

  // Insert the markup and the data into the template
  const htmlWithBody = htmlTemplate.replace(
    '<div id="root"></div>',
    `<div id="root">${appMarkup}</div>
    <script>window.__PRELOADED_STATE__ = ${safeStoreString};</script>`
  );

  // Return the rendered page to the user
  res.send(htmlWithBody);
});

It's important that you populate the store using initStore, and do so before rendering your app with ReactDOMServer.renderToString().

This is because your Node server might receive several requests from several users at the same time. All of these requests share the same global state, including the store object.

So, you must make sure that for each request, you empty the store, populate it with the appropriate data for the request, and render the markup at the same time. And by 'at the same time', I mean synchronously.

In the browser

In the entry point to your app, right before you call ReactDOM.hydrate(), call initStore() with the data that you sent from the server:

import { initStore } from 'react-recollect';

// other stuff

initStore(window.__PRELOADED_STATE__);

ReactDOM.hydrate(<App />, document.getElementById('root'));

This will take the data that you saved in the DOM on the server and fill up the Recollect store with it. You should only init the store once, before the initial render.

Note that initStore will trigger a render of collected components where applicable, and will fire afterChange.

batch(callback)

Added in 4.0.0

The batch function allows you to update the store multiple times, and be guaranteed that components will only be updated after all updates are made.

The callback function will be called immediately and should only contain synchronous code.

import { batch } from 'react-recollect';

const fetchData = async () => {
  const { posts, users, meta } = await fetch('/api').then((response) =>
    response.json()
  );

  batch(() => {
    store.posts = posts;
    store.users = users;
    store.meta = meta;
  });

  // now a render will be triggered for any components that use this data
};

Note that React already does a good job of batching multiple updates into a single render cycle. So only clutter up your code with batch if it results in an actual performance improvement.

useProps(propArray)

Added in 5.1.0

In most cases, you can rely on Recollect to know what data your component requires to render. However, Recollect can't know that your component will require a property in the future. If you reference a property:

  • in componentDidUpdate (and nowhere else), or
  • in UI that is only revealed after a change in state (perhaps a modal or drop-down)

... then Recollect won't know about it and your component won't be subscribed to changes in that property.

You can tell Recollect “I want to know if any of these properties change” by passing an array of store objects to the useProps function, like so:

import { collect, useProps } from 'react-recollect';

const MyComponent = ({ store }) => {
  const [showHiddenMessage, setShowHiddenMessage] = useState(false);

  // "This component might read `store.hiddenMessage` in the future"
  useProps([store.hiddenMessage]);

  return (
    <div>
      {showHiddenMessage && <p>{store.hiddenMessage}</p>}

      <button onClick={() => setShowHiddenMessage(true)}>
        Show hidden message
      </button>
    </div>
  );
};

export default collect(MyComponent);

Although useProps starts with the word 'use', it doesn't require React's Hooks mechanism, so it works just fine in versions before React 16.8. (For the curious, the implementation is literally just propArray.includes(0).)

Check out these tests for more usage examples.

PropTypes

Added in 5.2.0

As you've learnt by now, Recollect works by 'recording' which properties your component reads from the store while it renders. This poses a problem if you use the prop-types library, because it is going to read every property that you define in your prop types.

This could result in your component being subscribed to changes in a property it doesn't use, potentially concealing a problem that would only become apparent in production (where prop types aren't checked).

For this reason, react-recollect exports a proxied version of prop-types. It's exactly the same as the normal prop-types library, except that Recollect will pause its recording while your props are being checked.

import { PropTypes } from 'react-recollect';

const MyComponent = (props) => <h1>{props.title}</h1>;

MyComponent.propTypes = {
  title: PropTypes.string.isRequired,
};

export default MyComponent;

We recommended that you uninstall prop-types from your project and replace its usages with the Recollect version. That way no one can accidentally use the 'wrong' prop-types (if they didn't get this far in the readme).

If you use @types/prop-types you can uninstall that too, the types are built into react-recollect.

window.__RR__

Use window.__RR__ to inspect or edit the Recollect store in your browser's console.

__RR__ does not form part of the official API and should not be used in production. It might change between versions without warning and without respecting semver.

It has these properties, available in development or production:

  • debugOn() will turn on debugging. This shows you what's updating in the store and which components are being updated as a result, and what data those components are reading. Note that this can have a negative impact on performance if you're reading thousands of properties in a render cycle. Note also that it will 'collapse' all other console logs into the output (important for debugging, but not ideal a lot of the time).
  • debugOff() will surprise you
  • internals exposes some interesting things.

Via the internals object, you can get a reference to the store, which can be handy for troubleshooting. For example, typing __RR__.internals.store.loading = true in the console would update the store and re-render the appropriate components.

If you just log the store to the console, you will see a strange object littered with [[Handler]] and [[Target]] props. These are the proxies. All you need to know is that [[Target]] is the actual object you put in the store.

During development there are two more methods to help you inspect your app:

  • getListenersByComponent() will show you which store properties each component is subscribed to.
  • getComponentsByListener() is the inverse: it will show you which components are subscribed to which store properties.

You can optionally pass a string or regular expression to filter the results.

// Which components are subscribed to the user's status
__RR__.getComponentsByListener('user.status');

// What is <MyComponent> subscribed to?
__RR__.getListenersByComponent('MyComponent');

// What about the <Task> component where the prop `taskId` is 2?
__RR__.getListenersByComponent('Task2', (props) => props.taskId);

Check out the debug test suite for more examples.

Time travel

Added in 5.2.3

You can navigate through the history of changes to the store with the below functions (in your DevTools console):

  • __RR__.back() will go back to the state before the last store change.
  • __RR__.forward() will go forward again.
  • __RR__.goTo(index) will go to a particular index in the history.
  • __RR__.getHistory() will log out the entire history.
  • __RR__.clearHistory() clears the history.
  • __RR__.setHistoryLimit(limit) limits the number of store instances kept in history. Defaults to 50. Setting to 0 disables time travel. Is stored in local storage.

If you update the store with initStore or execute multiple updates within the batch function callback, those changes are recorded as a single history event.

Note that these time travel functions are only available during development, not in the production build.

Usage with TypeScript

Your store

Define the shape of your recollect store like this:

declare module 'react-recollect' {
  interface Store {
    someProp?: string[];
    somethingElse?: string;
  }
}

Put this in a declarations file such as src/types/RecollectStore.ts.

Using collect

Components wrapped in collect must define store in props — use the WithStoreProp interface for this:

import { collect, WithStoreProp } from 'react-recollect';

interface Props extends WithStoreProp {
  someComponentProp: string;
}

const MyComponent = ({ store, someComponentProp }: Props) => (
  // < your awesome JSX here>
);

export default collect(MyComponent);

If the only prop your component needs is store, you can use WithStoreProp directly.

import { WithStoreProp } from 'react-recollect';

const MyComponent = ({ store }: WithStoreProp) => <div>Hello {store.name}</div>;

Recollect is written in TypeScript, so you can check out the integration tests if you're not sure how to implement something.

(If you've got Mad TypeScript Skillz and would like to contribute, see if you can work out how to resolve the @ts-ignore in the collect module).

Project structure guidelines

The ideas described in this section aren't part of the Recollect API, they're simply a guide.

Two concepts are described in this section (neither of them new):

  • Selectors contain logic for retrieving and data from the store.

  • Updaters contain logic for updating the store. Updaters also handle reading/writing data from outside the browser (e.g. loading data over the network or from disk).

Cycle of life

In a simple application, you don't need to explicitly think in terms of updaters and selectors. For example:

  • defining checked={task.done} in a checkbox is a tiny little 'selector'
  • executing task.done = true when a user clicks that checkbox is a tiny little 'updater'

But as your app grows, it's important to keep your components focused on UI — you don't want 200 lines of logic in the onClick event of a button.

So there will come a point where moving code out of your components into dedicated files is necessary, and at this point, updaters and selectors will serve as useful concepts for organization.

In the examples below, I'll use a directory structure like this:

/my-app
 └─ src
    ├─ components
    ├─ store
    │  ├─ selectors
    │  └─ updaters
    └─ utils

(Fun fact: selector ends in 'or' because 'select' is derived from latin, while updater ends in 'er' because it was made up in 1941 and 'or' had gone out of style.)

Selectors

A simple case for a selector would be to return all incomplete tasks, sorted by due date.

export const getIncompleteTasksSortedByDueDate = (store) => {
  const tasks = store.tasks.slice();

  return tasks
    .sort((a, b) => a.dueDate - b.dueDate)
    .filter((task) => !task.done);
};

You would then use this function by importing it and referencing it in your component:

import { getIncompleteTasksSortedByDueDate } from '../store/selectors/taskSelectors';

const TaskList = ({ store }) => {
  const tasks = getIncompleteTasksSortedByDueDate(store);

  return (
    <div>
      {tasks.map((task) => (
        <Task key={task.id} task={task} />
      ))}
    </div>
  );
};

In this example, I'm passing the store object into the selector. But you could also do import { store } from 'react-recollect' in the selector file. (In Recollect version 4 and earlier, you had to pass the store through. From v5 onwards, you can use props.store or import the store.)

Maybe we want to conditionally show either all tasks or only incomplete tasks. Let's create a second selector. And while we're at it, move repeated sorting code out into its own function:

const getTasksSortedByDate = (tasks) => {
  const sortedTasks = tasks.slice();

  return sortedTasks.sort((a, b) => a.dueDate - b.dueDate);
};

export const getAllTasksSortedByDueDate = (store) =>
  getTasksSortedByDate(store.tasks);

export const getIncompleteTasksSortedByDueDate = (store) =>
  getTasksSortedByDate(store.tasks).filter((task) => !task.done);

And here's a more complex component with local state and a dropdown to show either all tasks or just those that aren't done:

class TaskList extends PureComponent {
  state = {
    filter: 'all',
  };

  render() {
    const { store } = this.props;

    const tasks =
      this.state.filter === 'all'
        ? getAllTasksSortedByDueDate(store)
        : getIncompleteTasksSortedByDueDate(store);

    return (
      <div>
        {tasks.map((task) => (
          <Task key={task.id} task={task} />
        ))}

        <select
          value={this.state.filter}
          onChange={(e) => {
            this.setState({ filter: e.target.value });
          }}
        >
          <option value="all">All tasks</option>
          <option value="incomplete">Incomplete tasks</option>
        </select>
      </div>
    );
  }
}

Now, when a user changes the dropdown, the component state will update, a re-render will be triggered, and as a result, a different selector will be used.

Updaters

An 'updater' is a function that updates the store in some way. As with selectors, you don't need to use updaters, they're just an organizational concept to minimize the amount of data logic you have in your component files.

A simple case for an updater would be to mark all tasks as done in a todo app:

import { store } from 'react-recollect';

export const markAllTasksAsDone = () => {
  store.tasks.forEach((task) => {
    task.done = true;
  });
};

You would reference this from a component by importing it then calling it in response to some user action:

import { markAllTasksAsDone } from '../store/updaters/taskUpdaters';

const Footer = () => (
  <button onClick={markAllTasksAsDone}>Mark all as done</button>
);

export default Footer;

You don't need to 'dispatch' an 'action' from an 'action creator' to a 'reducer'; you're just calling a function that updates the store.

And since these are just plain functions, they're 'composable'. Or in other words, if you want an updater that calls three other updaters, go for it.

Loading data with an updater

Let's create an updater that loads some tasks from an api when our app mounts. It will need to:

  1. Set a loading indicator to true
  2. Fetch some tasks from a server
  3. Save the data to the store
  4. Set the loading indicator to false
export const loadTasksFromServer = async () => {
  store.loading = true;

  store.tasks = await fetchJson('/api/get-my-tasks');

  store.loading = false;
};

You might call this function like so:

import { loadTasksFromServer } from '../store/updaters/taskUpdaters';

class TaskList extends React.Component {
  componentDidMount() {
    loadTasksFromServer();
  }

  render() {
    // just render stuff
  }
}

Asynchronous updaters

Did you notice that we've already covered the super-complex topic of asynchronicity? You can update the Recollect store whenever you like, so you don't need to do anything special to get asynchronous code to work.

Testing an updater

Let's write a unit test to call our updater and assert that it put the correct data in the store. The function we're testing is async, so our test will be async too:

test('loadTasksFromServer should update the store', async () => {
  // Execute the updater
  await loadTasksFromServer();

  // Check that the final state of the store is what we expected
  expect(store).toEqual(
    expect.objectContaining({
      loading: false,
      tasks: [
        {
          id: 1,
          name: 'Fetched task',
          done: false,
        },
      ],
    })
  );
});

Pretty easy, right?

We can make it less easy.

Maybe we want to assert that loading was set to true, then the tasks loaded, and then loading was set to false again.

Well, Recollect exports an afterChange function designed to call a callback every time the store changes. If we pass it a Jest mock function, Jest will conveniently keep a record of each time the store changed.

Also, no one likes half an example, so here's the entire test file:

import { afterChange, store } from 'react-recollect';
import { loadTasksFromServer } from './taskUpdaters';

jest.mock('../../utils/fetchJson', () => async () => [
  {
    id: 1,
    name: 'Fetched task',
    done: false,
  },
]);

test('loadTasksFromServer should update the store', async () => {
  // Create a mock
  const afterChangeHandler = jest.fn();

  // Pass the mock to afterChange. Jest will record calls to this function
  // and therefore record calls to update the store.
  afterChange(afterChangeHandler);

  // Execute our updater
  await loadTasksFromServer();

  // afterChangeHandler will be called with the new version of the store and the path that was changed
  const firstChange = afterChangeHandler.mock.calls[0][0];
  const secondChange = afterChangeHandler.mock.calls[1][0];
  const thirdChange = afterChangeHandler.mock.calls[2][0];

  expect(firstChange.changedProps[0]).toBe('loading');
  expect(firstChange.store.loading).toBe(true);

  expect(secondChange.changedProps[0]).toBe('tasks');
  expect(secondChange.store.tasks.length).toBe(1);

  expect(thirdChange.changedProps[0]).toBe('loading');
  expect(thirdChange.store.loading).toBe(false);

  // Check that the final state of the store is what we expected
  expect(store).toEqual(
    expect.objectContaining({
      loading: false,
      tasks: [
        {
          id: 1,
          name: 'Fetched task',
          done: false,
        },
      ],
    })
  );
});

FAQ

How does it work?

Every object you add to the Recollect store gets wrapped in a Proxy. These proxies allow Recollect to intercept reads and writes. It's similar to defining getters and setters, but far more powerful.

If you were to execute the code below, that site object would be wrapped in a proxy.

store.site = {
  title: 'Page one',
};

(Items are deeply/recursively wrapped, not just the top level object you add.)

Now, if you execute the code store.site.title = 'Page two', Recollect won't mutate the site object to set the title property. Recollect will block the operation and instead create a clone of the object where title is 'Page two'. Recollect keeps a reference between the old and the new site objects, so any attempt to read from or write to the 'old version' will be redirected to the 'new version' of that object.

In addition to intercepting write operations, the proxies also allow Recollect to know when data is being read from the store. When you wrap a component in collect, you're instructing Recollect to monitor when that component starts and stops rendering. Any read from the store while a component is rendering results in that component being 'subscribed' to the property that was read.

Bringing it all together: when some of your code attempts to write to the store, Recollect will clone as described above, then notify all the components that use the property that was updated, passing those components the 'next' version of the store.

What sort of stuff can go in the store?

You can store anything that's valid JSON. If that's all you want to do, you can skip the rest of this section.

The below applies to 4.0.0 and up

Recollect will store data of any type, including (but not limited to):

  • undefined
  • Map
  • Set
  • RegExp objects
  • Date objects

Recollect will monitor changes to:

  • Primitives (string, number, boolean, null, undefined, symbol)
  • Plain objects
  • Arrays
  • Maps (see limitations below)
  • Sets (see limitations below)

Recollect will store, but not monitor attempted mutations to other objects. For example:

  • store.date = new Date() is fine.
  • store.date.setDate(7) will not trigger an update.
  • store.uIntArray = new Uint8Array([3, 2, 1]) is fine.
  • store.uIntArray.sort() will not trigger an update.

The same applies to WeakMap, DataView, ArrayBuffer and any other object you can think of.

If there's a data type you want to store and mutate that isn't supported, log an issue and we'll chat.

Other things that aren't supported (or haven't been tested):

  • Functions (e.g. getters, setters, or other methods)
  • Class instances (if this would be useful to you, log an issue and we'll chat)
  • Properties defined with Object.defineProperty()
  • String properties on arrays, Maps and Sets (I don't mean string keys in maps, I mean actually creating a property on the object itself — a fairly unusual thing to do)
  • Proxy objects (if this would be useful to you, log an issue and we'll chat)
  • Linking (e.g. one item in the store that is a reference to another item in the store)

You can even store components in the store if you feel the need. This hasn't been performance tested, so proceed with caution.

const Page = collect(({ store }) => {
  const { Header, Footer, Button } = store.components;

  return (
    <React.Fragment>
      <Header title="Page one" />

      <Button onClick={doSomething} />

      <Footer />
    </React.Fragment>
  );
});

Map and Set limitations

Updating an object key of a map entry will not always trigger a render of components using that object. But in most cases you'd be storing your data in the value of a map entry, and that works fine.

Similarly, updating an object in a set may not trigger an update to components using that object (adding/removing items from a set works fine).

When will my components be re-rendered?

Short version: when they need to be, don't worry about it.

Longer version: if a property is changed (e.g. store.page.title = 'Page two'), any component that read that property when it last rendered will be updated. If an object, array, map or set is changed (e.g. store.tasks.pop()) any component that read that object/array/map/set will be updated.

Check out tests/integration/updating for the full suite of scenarios.

How many components should I wrap in collect?

You can wrap every component in collect if you feel like it. As a general rule, the more you wrap in collect, the fewer unnecessary renders you'll get, and the less you'll have to pass props down through your component tree.

There is one rule you must follow though: do not pass part of the store into a collected component as props. Don't worry if you're not sure what that means, you'll get a development-time error if you try. This issue explains why.

When dealing with components that are rendered as array items (e.g. <Product>s in a <ProductList>), you'll probably get the best performance with the following setup:

  • Wrap the parent component in collect.
  • Don't wrap the child component in collect
  • Pass the required data to the child components as props.
  • Mark the child component as pure with memo() or PureComponent.

With this arrangement, when an item in the array changes (e.g. a product is starred), Recollect will immutably update only that item in the store, and trigger the parent component to update. React will skip the update on all the children that didn't change, so only the one child will re-render.

For a working example, see the Recollect demo on CodeSandbox.

Can I use this with class-based components and functional components?

Yep and yep.

Hooks?

Yep.

Will component state still work?

Yes, but be careful. If a change in state reveals some new UI, and a property from the store is only read in that UI (not elsewhere in the component) then Recollect won't be aware of it, and won't update your component if it changes.

Use the useProps function to make sure your component is subscribed to changes in this property.

Do lifecycle methods still fire?

Yep. Recollect has no effect on componentDidMount, componentDidUpdate and friends.

Why isn't my componentDidUpdate code firing?

If you have a store prop that you only refer to in componentDidUpdate (e.g. store.loaded), then your component won't be subscribed to changes in that prop. So when store.loaded changes, your component might not be updated.

Use the useProps function to make sure your component is subscribed to changes in this property.

Can I use this with shouldComponentUpdate()?

Yes, but no, but you probably don't need to.

The React docs say of shouldComponentUpdate():

This method only exists as a performance optimization. Do not rely on it to “prevent” a rendering, as this can lead to bugs ... In the future React may treat shouldComponentUpdate() as a hint rather than a strict directive, and returning false may still result in a re-rendering of the component

So, if you're using shouldComponentUpdate for performance reasons, then you don't need it anymore. If the shouldComponentUpdate method is executing, it's because Recollect has told React to update the component, which means a value that it needs to render has changed.

Can I wrap a PureComponent or React.memo in collect?

There's no need. The collect function wraps your component in a PureComponent and there's no benefit to having two of them.

It's a good idea to wrap other components in PureComponent or React.memo though — especially components that are rendered in an array, like <Todo>. If you have a hundred todos, and add one to the list, you can skip a render for all the existing <Todo> components if they're marked as pure.

Can I use this with Context?

Yes. Recollect doesn't interfere with other libraries that use Context.

You shouldn't need to use Context yourself though. You have a global store object that you can read from and write to anywhere.

Can I use this with refs?

Yes, refs just work, as long as you don't use the reserved name 'ref' (React strips this out). You can use something like inputRef instead. For an example, see this test

Can I have multiple stores?

No. There is no performance improvement to be had, so the desire for multiple stores is just an organizational preference. For this, you can use 'selectors' to focus on a subset of your store.

Can I use Recollect without React?

Yes! You can use store without using collect. Pair this with afterChange to have an object that notifies you when its changed. For an example, check out tests/integration/nodeJs.js

I'm getting a no-param-reassign ESLint error

You can add 'store' as a special case in your ESLint config so that the rule allows you to mutate the properties of store.

{
  "rules": {
    "no-param-reassign": [
      "error",
      {
        "props": true,
        "ignorePropertyModificationsFor": ["store"]
      }
    ]
  }
}

Check out the no-param-reassign rule in this repo's eslint config for the syntax.

Tell me about your tests

  • There's 100+ integration/unit tests in the tests directory.
  • There's a /demo directory with a Create React App site using Recollect. This has a Cypress test suite.
  • There's /demo/public/browser.html for manual testing of the UMD build of Recollect.

How big is it?

3—5 KB, depending on what else you've got installed. If you're coming from Redux land, you'll save about 1 KB in library size, but the big savings come from getting rid of all your reducers.

Is reading/writing via a proxy slow?

Slow, no. Slower than vanilla object operations, yes.

Let's quantify with a case study: in an app that has a store with ~80,000 properties, ~30,000 of them proxied objects and ~1,000 component listeners, updating a chunk of data requiring 50 new proxies takes ~2 milliseconds (on desktop). For that app, 2ms was considered insignificant compared to the ~90ms spent on the resulting render cycle.

If you're processing big data and facing performance troubles, open an issue and we'll chat.

Dependencies

Recollect has a peer dependency of React >=15.3.

Alternatives

If you want IE support, use Redux.

If you want explicit 'observables' and multiple stores, use MobX.

If you want a walk down memory lane, use Flux.

Also there is a library that is very similar to this one (I didn't copy, promise) called react-easy-state.

Is it really OK to drop support for IE?

Sure, why not! Imagine: all that time you spend getting stuff to work for a few users in crappy old browsers could instead be spent making awesome new features for the vast majority of your users.

For inspiration, these brave websites have dropped the hammer and dropped support for IE:

  • GitHub (owned by Microsoft!)
  • devdocs.io
  • Flickr
  • Codepen

react-recollect's People

Contributors

danawoodman avatar davidgilbertson avatar dependabot[bot] avatar dijs avatar stepanh 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

react-recollect's Issues

Not SSR safe

Rendering a component with store server side ends in an explosion:

ReferenceError: localStorage is not defined

Note: I guess that on server, store could be just a plain object, no need for proxy.

Bundle with Webpack or Rollup

Using plain Babel is OK, but I think it has problems with circular references.

In general.js:

export const TEST = 'hello';

and in debugger.js

import { TEST } from './general';
console.log(TEST);

And I get
image

Because debug imports general, which imports proxy, which imports proxyHandler, which imports collect, which imports store, which imports debug.

Clarification on using the store in React components

Great plugin, I have been testing it for some days and it works great.
Just a clarification on which store to use with a collected React component (I am a bit confused on this).

As I understood from the documentation a component "reading" from the store should be collected and use the store that the HOC provides in the props.

My question is when this component makes changes to the store (that will affect other components), should it make them to the store from props, the one imported from the plugin or it doesn't matter?
A follow up question is if the component only modifies the store but doesn't read from it, should it be collected? And if so, the first question again, which store to use?

Thanks!

Create ToDoMVC style TODO app

There's not much point actually submitting something to TodoMVC, but doing a CodeSandbox with all the features might be a useful learning tool.

Approach to updating deeply nested data?

deep references to items in the store may be broken if you modify the store. I'd be interested to hear about cases where this is proving unpleasant. Please feel free to open an issue with a code snippet, even if you think it's something that can't be fixed.

Me! So, I have an object tree like: store.devices[id].services[id].something

I have to, because of possible null/undefined values, check to see that the particular item exists when doing an update operation.

Right now, I am doing something kinda ugly:

// not actual code, but similar

if (!store.devices[dID]) throw new Error('No device!')
if (!store.devices[dID].services || !store.devices[dID].services) throw new Error('No services!')
if (!store.devices[dID].services[sID]) throw new Error('No service!')

store.devices[dID].services[sID].foo = true
await something()
store.devices[dID].services[sID].foo = false

Is there a better way to do this?

What if my object was store.devices[dID].services[sID].foo[a].bar[b].baz = true?

Pave the way for time travel

I don't want to implement time travel, but want to allow middleware to. I'm thinking something like this:

  • currently I pass the next store to afterChange. I could also pass the previous store.
  • I can pass an array of React components that were updated
  • Someone could write middleware to be used like afterChange(someRewindLibrary) that exposes back() and forward() methods (e.g. in the console)

I think that'll work. I need to have a think about whether just passing the old store back to the components that updated is enough. I can't think why it wouldn't be.

Allow SSR/add resetStore function

If Recollect was used for server rendering, multiple network requests would cumulatively add to the store. Like Redux does createStore for each new render, I should have a resetStore or something like that, that creates a clean version of the store.

In fact, on the server I don't need to add listeners, and can mute the proxy. Think about what a server-rendering mode might look like.

Bring all docs into the readme

I've just realised that I hate when people split out readmes so I can't ctrl+f search. Bring everything from /docs into README.md.

Maybe a TOC, and make it clear to the reader that they don't need to know it all, and maybe one day GitBook when I've got nothing better to do.

Better path definition in afterChange

Currently afterChange is called with a string representing the prop path that was changed. E.g. page.tasks.2.done.

This is no good. If tasks was a Map object, it could have one key '2' and another key 2 pointing to separate values.

The internals now use an array for paths, so I'll expose that to users. Will require a major version bump.

Resolved by #69

RangeError: Maximum call stack size exceeded

Why does the associated component always seem to get this error

RangeError: Maximum call stack size exceeded

whenever i want to set a new value for an array store property. Please, check the code snippet below
axios.delete('/v1/order/deleteorder/' + id) .then(res => { let orders = this.props.store.orders; this.props.store.orders=orders.filter(({_id})=>{ return _id !==id }) // this.props.history.location('/order') })

I wish to filter the store.order and set new value after deleting an order but i get that error.
Please do let me know what i am doing wrong.

Fix sort in < V8 7.0

V8 7.0 changed the way Array#sort works (so the problem is only in Node.js < 11 and Chrome <70)

In V8 6.x and earlier, something funny is going on when I try to sort using a comparison function. I suspect because with each change to the array, I'm updating state.nextStore, and whatever's happening internally to do the sort is losing a reference.

The pre-7.0 behaviour is explained a bit here: https://v8.dev/blog/array-sort#history

The fix will probably be to bypass all proxies for sort, let the engine do the sort (e.g. on a clone array), then update nextStore with the results.

Remove Babel (investigate)

If I use React.createClass and require instead of import then I can maybe do away with Babel.
Is there any point to this?

Reasons not to:

  • I run the risk of accidentally doing a trailing comma in a function's argument list or something that will blow up somewhere.
  • Babel can strip out comments which is nice
  • Not sure how imports into a user's es6 module file work if my modules are commonjs.

Batch component updates (maybe)

This could have large implications. For one, all tests that change the store and check for a response would need to be async.

But what of non-tests. Is this likely to case trouble in userland? If React itself is headed toward async updates then maybe this doesn't have such a big impact, negative or positive.

Suss out the scenarios...

Resolved by #73

Getting more people to use recollect?

What are you plans/thoughts/ideas to get more people to use recollect? I think it's got a lot of promise and would be good to see some more activity!

Cannot set store twice in single action

If store.tasks is not defined, this fails:

<button
  onClick={() => {
    if (typeof props.store.tasks === 'undefined') {
      // set the store once
      props.store.tasks = [];
    }

    // expect tasks to be defined
    props.store.tasks.push({ name: 'A new task' });
  }}
>
  Add a task
</button>

Because the first props.store.tasks = [] doesn't update the store before the next line store.tasks.push is called. The order is:

  1. Call props.store.tasks = []
  2. The collect HOC has setState called with the new store. But, React will wait for the other synchronous code to finish before triggering a re-render, so...
  3. The event handler code continues, but props.store hasn't yet been updated (React is waiting for this code to finish) so props.store.tasks is still undefined and props.store.tasks.push() fails.

Using the global store instead of the one passed in as props resolves this, but it's not great DX to have the rule "when reading from the store, use props.store, when setting in the store, use the global store".

And what if I want to do two things with the passed in task object? I can't get a reference to this in the global store.

Is it time to name them differently? Does that help?

TypeError: Cannot create proxy with a non-object as target or handler

First off, really cool library. I hope it continues to evolve because it's a much more natural way to handle state.

When trying to use the docs, I immediately hit a stumbling block however. I installed recollect into a brand new create-react-app project, imported store and then did:

store.tasks = ['a', 'b', 'c']

And got the following exception:

TypeError: Cannot create proxy with a non-object as target or handler

If I instead do:

store.tasks = []

It works.

This example comes from the readme, so I'm sure I'm not the only one having this issue?

Thanks for your help!

Add docs for unit testing

For selectors it's easy and probably obvious
For updaters it's easy and maybe not obvious
For components it's easy and medium-obvious

Add Cypress

While I'm at it:

  • organise tests better, split out object/array/map/set
  • tests with hooks
  • Bump React version used in tests

Better Map() and Set() support

store.set = new Set();
const someObject = {prop: 'value'};
store.set.add(someObject);
someObject.prop = 'new value'; // Doesn't trigger a render

This is surprisingly complicated. If a user stores an item in a set, and passed that item to a component to render, then changes the content of that item, the item will be cloned (nothing is mutated). But everything in the store has a path, and for a Set, the value is the path. But the value changes! The issue is the same for Maps where the key is an object.

Also, a change to the key of a Map (assuming the key is an object) should update any component using that key. But the key is part of the path, and changing it will clone it, so the path breaks.

One option is to use indexes in prop paths, rather than the value. This is less readable to the user, but reading from the path is probably fairly rare.

Another (potential) option is to use a WeakMap to maintain a reference between an object and its clone.

License?

That's all, this seems neat though.

Update afterChange parameters to be an object

I think afterChange has too many parameters. As it turns out, it's super useful for testing, but I have to do this:

expect(afterChangeHandler.mock.calls[0][1]).toBe('store.loading');
expect(afterChangeHandler.mock.calls[0][0].loading).toBe(true);

This would be nicer:

const storeChange = afterChangeHandler.mock.calls[0];
expect(storeChange.path).toBe('store.loading');
expect(storeChange.store.loading).toBe(true);

And the usual case would change from this:

afterChange(store => {
  localStorage.setItem('site-data', JSON.stringify(store));
});

To this:

afterChange(({ store }) => {
  localStorage.setItem('site-data', JSON.stringify(store));
});

Proposed signature would be

afterChange(({ store, propPath, prevStore, components }) => {});

This would be a breaking change, so v3.

Docs would need to be updated.

Prevent update if from/to values are the same

I should be able to short circuit some work by checking if 'from' and 'to' are strictly equal in set().

E.g. if a task is done, setting task.done = true shouldn't even trigger an update.

Is there a scenario where this is bad? Would two strictly equal prev/next values ever need to trigger an update? React would always just bail during the dom diff anyway, right?

Note: there's a spot where I mention this in updater-patter.md - that will need updating.

Properties set in componentDidMount aren't registered

If I set store.loading = true in componentDidMount, then fetch some data, then set store.loading = false, the component will never render the loading state.

Problem, the components won't be updated (when store.loading is changed) if they haven't mounted. Or technically, if the HOC hasn't mounted.

Solution one: when triggering an update, don't check if the HOC has mounted, check if the wrapped component has mounted. I don't know how to do this.

Solution two: in the HOC, in addition to tracking if the component is mounted, check if the component is in the initial render cycle. Then, if an update occurs before mounting (of the HOC) is done, still call setState() and let React handle the next render cycle.

Resolve circular dependencies

And general tidy up

Notes for release:

Breaking changes:

  • Prop paths no longer have the superfluous store. prefix.
  • afterChange event has updatedComponents and changedProps properties replacing components and propPath
  • Updating the store during a render (technically, a collection cycle) results in an error. This includes code in componentDidMount().

New stuff:

  • batch

DevTools

Before you ask... I read the readme.

Use __RR__.debugOn() to turn on debugging. The setting is stored in local storage, so will persist while you sleep. You can combine this with Chrome's console filtering, for example to only see 'UPDATE' or 'SET' events. Who needs professional, well made dev tools extensions!

Yes, this is awesome. EXCEPT, Google freaking ripped out the filtering feature in the console read more. So that really makes debugging in the console very difficult.

Any plans on reusing the popular redux dev tools to show changes in the store? MobX does this, and it works really well.

If you don't have time, just let me know and I may be able to spend some time looking into it.

Lastly, this is a really great little library. I have already used it on two of my personal projects. Thank you for your hard work!

TypeScript definitions are not in NPM release

Hi David, nice idea for a lib & digging your writing style!

Having a go at setting it up with SSR+Typescript and met some resistance:

  1. index.d.ts is missing in the package published to NPM.
  2. store: object causes errors in strict TS
  3. PureComponent is too specific
  4. collect should return ComponentType

How about this:

recollect's index.d.ts:

declare module 'react-recollect' {
  import * as React from 'react';

  interface GlobalState {
  }
  
  interface WithStoreProp {
    store: GlobalState;
  }
  
  interface CollectorComponent extends React.Component {
    update(store: object): void,
    _name: string,
  }
  
  export function collect<P extends object>(Component: React.ComponentType<P>): React.ComponentType<P & WithStoreProp>;
  
  export const store: GlobalState;
  
  export function afterChange(callback: (
    newStore: GlobalState,
    propPath: string,
    updated: CollectorComponent[],
    oldStore: GlobalState
  ) => void): void;
}

User would define interface for their global state in their project:


declare module 'react-recollect' {
  interface GlobalState {
    test?: string[];
    userName?: string; 
  }
}

Disclaimer: not fully tested

Add tests for isolation

Add a test scenario that ensure changes to one part of the store don't trigger updates in components that never read from that part of the store (e.g. a chat section in the tasks page)

Add index.d.ts

Or maybe a @types/react-recollect thing. Learn how all this works, what do the editors pick up?

Question: How would you create "selectors"

Would using something like reselect be possible with recollect? For example, creating a selector that filters a list of items in your store and that automatically updates when the store changes?

Is there another way to approach this?

Thanks and keep up the good work!

Spike: smarter updating of listeners

Part 1 - parent path listeners

Currently, if there are two listeners:

  • <ParentComponent> listening to store.tasks
  • <ChildComponent> listening to store.tasks.1.done

Then if the prop at path store.tasks.1.done is updated, then both components are updated. I did this for a specific reason in the early days and really should have documented why.

Edit, this is because a child component will often not get its data from the store passed by collect() - it will get the data passed from the parent (e.g. a <TaskList> will pass each task object from an array to a child <Task> component). So if I don't update the <TaskList> component with the new store, it wouldn't know if a task was ticked.

At the very least I need to add a test to protect/document this.

Part 1 result: no change to code, but a change to guidance:

"You don't need to wrap a component in collect unless you want access to the store in that component". Two reasons for this:

  • If <TaskList> references store.tasks and renders a bunch of <Task> components, and the <Task> component is not wrapped in collect, then all of the reads in the <Task> components (store.tasks.0.name, etc) are attributed to the <TaskList> - so it's listening on everything its children rendered.
  • And a second reason I've just forgotten.

Also, if you aren't wrapping your components in collect() make sure you're using PureComponent or React.memo. To not use these is to be slow for no reason.

Part 2 - child path listeners

I also update down the prop tree. E.g. if a prop store.tasks changes (an entire array is overwritten with a new array), then I'll trigger updates on components listening to paths that start with that. So, store.tasks.0, store.tasks.0.done and so on.

In other words, if you're listening to any prop 'inside' a prop that changes, you need to know about that parent prop changing. But, if a listener is listening to a child prop and is a child component then it won't need to update, because the parent component will update. But I have no way of knowing the relationships between components.

I don't think there's much I can do here. And, I think this falls under the control of React, which will not be wasteful even though I've requested redundant updates.

Part 2 Result: no change

Part 3 - tracking previous value

What if, for each listener, I stored the last value at the prop path a component is listening to? Then, when updating, if the value hasn't changed, don't bother updating. Would this catch anything?

  • For updating listeners with an exact match on the path, it makes no difference (the proxy handler won't trigger an update if the value didn't change).
  • For listeners to a parent prop path, it won't matter, because if a value changed for any prop path, then all parent objects in the store will be different (because immutability).
  • But for listeners to a child path (I overwrite store.data and some component is listening to store.data.page.title), then if the title didn't change, I don't need to update it. But a component listening to store.data.page.title must also be listening on store.data.page and that will be a new object.

Also, would this take a lot of memory?

Part 3 Result: no change

Part 4 - listening on objects and arrays

Here's a question: do I need to listen to props that are objects/arrays? (And I mean {} objects, not null, etc). You can't actually use an array or an object without accessing one of its child properties.
I mean, you can't render a task to the screen. You can only render task.done and task.name and task.id and so on. Even if you called JSON.stringify(task) that calls everything under the hood.

Hmmm, I think I do need to listen to objects/array, because of part 1 above. I might only read an object in a parent component (store.page), then pass that object to a child component (regardless of whether it's wrapped in collect or not) and it reads a property that's a string page.title. So yeah, I need to be listening for changes to the object, because Recollect might not be aware of child components listening to child props.

Part 4 result: no change


Well this is good and bad. I think it's about it's efficient as it can be regarding updating, unless I can think of a way to not trigger an update on a child component if I'm updating a component further up the tree (and only bother if React is handling this).

Fix typo on docs

In your first example:

import React from 'react';
import { collect } from 'react-recollect';
import Task from './Task';

-const TaskList = ({ store ) => (
+const TaskList = ({ store }) => (
  <div>
    {store.tasks.map(task => (
      <Task key={task.id} task={task} />
    ))}
    
    <button onClick={() => {
      store.tasks.push({
        name: 'A new task',
        done: false,
      });
    }}>
      Add a task
    </button>
  </div>
);

export default collect(TaskList);

I'm probably doing something wrong

I've got a component, say <BigFreakingList /> that has hundreds of items.

That then has a bunch of sub components, say <SomeComplexComponent />

Then finally I have <ListItem />

The only component I have wrapped in collect() is <BigFreakingList />.

I have a method called Manager.update(), which I pass down from <SomeComplexComponent /> to <ListItem /> which basically does:

import { store } from 'react-recollect'

class Manager {
  async update () {
    store.items[id].loading = true
    await something()
    store.items[id].loading = false
  }
}

However, when <ListItem /> is triggering update via clicking a button and I have debugOn, I get many hundreds of console log statements even though, I would assume I should just get a few related to updating the one store.items[id] item.

Question is, what am I doing wrong? Should I wrap all components in collect?

My component does the loading update properly on the first pass, then just gets stuck:

EDIT: This is because I was assigning to a variable, making a change, then making another change rather than re-accessing the value from the store.

I'm getting pages upon pages of logs like this:

Question: Would this work with Stencil?

This is sort of a random question, but would this work with the Stencil framework?

I've been playing with Stencil, which creates Web Components with a very similar API to React:
https://stenciljs.com

I gave this a shot but it seems to fail on an import so I can't test this.

[ ERROR ]  Rollup: Missing Export: src/components/app-home/app-home.js:1:9
           'store' is not exported by
           node_modules/react-recollect/dist/index.js

Would be interesting to see if recollect could be made agnostic of React?

Spike: can I warn when using global store in a component

Users shouldn't import { store } from 'react-recollect' and the read from that in a component.
I never actually read the first segment of a prop path (called 'store') so I could re-purpose that to be 'next-store'. Then, in the proxy get trap I could check that the path starts with that.

Or maybe I'll have to version them. So, each time I rebuild the store I update the first part of the path so it's store-v1.tasks.1.done or whatever.

But I don't update every prop, so some props will legitimately be old version.s

Maybe this isn't possible at all.

Is there some other way to know 'which' store is being read from? Somehow mix something into the proxy handler? Have a createProxyHandler('v2'). No, that won't work.

I actually don't need to be 100% with the warning. So maybe I can just say for any individual read at a prop-path, if that prop path is not the most recent one, then I can say "hey, looks like you're reading from the global store object inside a component. Don't do that. RTFM".

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.