Giter Site home page Giter Site logo

hubspot / general-store Goto Github PK

View Code? Open in Web Editor NEW
177.0 139.0 27.0 1.25 MB

Simple, flexible store implementation for Flux. #hubspot-open-source

Home Page: http://github.hubspot.com/general-store

License: MIT License

JavaScript 62.97% TypeScript 37.03%
react hubspot flux dispatcher data store javascript

general-store's Introduction

HubSpot/general-store

NPM version Build Status

general-store aims to provide all the features of a Flux store without prescribing the implementation of that store's data or mutations.

Briefly, a store:

  1. contains any arbitrary value
  2. exposes that value via a get method
  3. responds to specific events from the dispatcher
  4. notifies subscribers when its value changes

That's it. All other features, like Immutability, data fetching, undo, etc. are implementation details.

Read more about the general-store rationale on the HubSpot Product Team Blog.

Install

# npm >= 5.0.0
npm install general-store

# yarn
yarn add general-store
// namespace import
import * as GeneralStore from 'general-store';
// or import just your module
import { define } from 'general-store';

Create a store

GeneralStore uses functions to encapsulate private data.

var dispatcher = new Flux.Dispatcher();
function defineUserStore() {
  // data is stored privately inside the store module's closure
  var users = {
    123: {
      id: 123,
      name: 'Mary',
    },
  };

  return (
    GeneralStore.define()
      .defineName('UserStore')
      // the store's getter should return the public subset of its data
      .defineGet(function() {
        return users;
      })
      // handle actions received from the dispatcher
      .defineResponseTo('USER_ADDED', function(user) {
        users[user.id] = user;
      })
      .defineResponseTo('USER_REMOVED', function(user) {
        delete users[user.id];
      })
      // after a store is "registered" its action handlers are bound
      // to the dispatcher
      .register(dispatcher)
  );
}

If you use a singleton pattern for stores, simply use the result of register from a module.

import { Dispatcher } from 'flux';
import * as GeneralStore from 'general-store';

var dispatcher = new Dispatcher();
var users = {};

var UserStore = GeneralStore.define()
  .defineGet(function() {
    return users;
  })
  .register(dispatcher);

export default UserStore;

Dispatch to the Store

Sending a message to your stores via the dispatcher is easy.

dispatcher.dispatch({
  actionType: 'USER_ADDED', // required field
  data: {
    // optional field, passed to the store's response
    id: 12314,
    name: 'Colby Rabideau',
  },
});

Store Factories

The classic singleton store API is great, but can be hard to test. defineFactory() provides an composable alternative to define() that makes testing easier and allows you to extend store behavior.

var UserStoreFactory = GeneralStore.defineFactory()
  .defineName('UserStore')
  .defineGetInitialState(function() {
    return {};
  })
  .defineResponses({
    USER_ADDED: function(state, user) {
      state[user.id] = user;
      return state;
    },
    USER_REMOVED: function(state, user) {
      delete state[user.id];
      return state;
    },
  });

Like singletons, factories have a register method. Unlike singletons, that register method can be called many times and will always return a new instance of the store described by the factory, which is useful in unit tests.

describe('UserStore', () => {
  var storeInstance;
  beforeEach(() => {
    // each test will have a clean store
    storeInstance = UserStoreFactory.register(dispatcher);
  });

  it('adds users', () => {
    var mockUser = { id: 1, name: 'Joe' };
    dispatcher.dispatch({ actionType: USER_ADDED, data: mockUser });
    expect(storeInstance.get()).toEqual({ 1: mockUser });
  });

  it('removes users', () => {
    var mockUser = { id: 1, name: 'Joe' };
    dispatcher.dispatch({ actionType: USER_ADDED, data: mockUser });
    dispatcher.dispatch({ actionType: USER_REMOVED, data: mockUser });
    expect(storeInstance.get()).toEqual({});
  });
});

To further assist with testing, the InspectStore module allows you to read the internal fields of a store instance (e.g. InspectStore.getState(store)).

Using the Store API

A registered Store provides methods for "getting" its value and subscribing to changes to that value.

UserStore.get(); // returns {}
var subscription = UserStore.addOnChange(function() {
  // handle changes!
});
// addOnChange returns an object with a `remove` method.
// When you're ready to unsubscribe from a store's changes,
// simply call that method.
subscription.remove();

React

GeneralStore provides some convenience functions for supplying data to React components. Both functions rely on the concept of "dependencies" and process those dependencies to return any data kept in a Store and make it easily accessible to a React component.

Dependencies

GeneralStore has a two formats for declaring data dependencies of React components. A SimpleDependency is simply a reference to a Store instance. The value returned will be the result of Store.get(). A CompoundDependency depends on one or more stores and uses a "dereference" function that allows you to perform operations and data manipulation on the data that comes from the stores listed in the dependency:

const FriendsDependency = {
  // compound fields can depend on one or more stores
  // and specify a function to "dereference" the store's value.
  stores: [ProfileStore, UsersStore],
  deref: props => {
    friendIds = ProfileStore.get().friendIds;
    users = UsersStore.get();
    return friendIds.map(id => users[id]);
  },
};

Once you declare your dependencies there are two ways to connect them to a react component.

useStoreDependency

useStoreDependency is a React Hook that enables you to connect to a single dependency inside of a functional component. The useStoreDependency hook accepts a dependency, and optionally a map of props to pass into the deref and a dispatcher instance.

function FriendsList() {
  const friends = GeneralStore.useStoreDependency(
    FriendsDependency,
    {},
    dispatcher
  );
  return (
    <ul>
      {friends.map(friend => (
        <li>{friend.getName()}</li>
      ))}
    </ul>
  );
}

connect

The second option is a Higher-Order Component (commonly "HOC") called connect. It's similar to react-redux's connect function but it takes a DependencyMap. Note that this is different than useStoreDependency which only accepts a single Dependency, even though (as of v4) connect and useStoreDependency have the same implementation under the hood. A DependencyMap is a mapping of string keys to Dependencys:

const dependencies = {
  // simple fields can be expressed in the form `key => store`
  subject: ProfileStore,
  friends: FriendsDependency,
};

connect passes the fields defined in the DependencyMap to the enhanced component as props.

// ProfileContainer.js
function ProfileContainer({ friends, subject }) {
  return (
    <div>
      <h1>{subject.name}</h1>
      {this.renderFriends()}
      <h3>Friends</h3>
      <ul>
        {Object.keys(friends).map(id => (
          <li>{friends[id].name}</li>
        ))}
      </ul>
    </div>
  );
}

export default connect(
  dependencies,
  dispatcher
)(ProfileComponent);

connect also allows you to compose dependencies - the result of the entire dependency map is passed as the second argument to all deref functions. While the above syntax is simpler, if the Friends and Users data was a bit harder to calculate and each required multiple stores, the friends dependency could've been written as a composition like this:

const dependencies = {
  users: UsersStore,
  friends: {
    stores: [ProfileStore],
    deref: (props, deps) => {
      friendIds = ProfileStore.get().friendIds;
      return friendIds.map(id => deps.users[id]);
    },
  },
};

This composition makes separating dependency code and making dependencies testable much easier, since all dependency logic doesn't need to be fully self-contained.

Default Dispatcher Instance

The common Flux architecture has a single central dispatcher. As a convenience GeneralStore allows you to set a global dispatcher which will become the default when a store is registered, the useStoreDependency hook is called inside a functional component, or a component is enhanced with connect.

var dispatcher = new Flux.Dispatcher();
GeneralStore.DispatcherInstance.set(dispatcher);

Now you can register a store without explicitly passing a dispatcher:

const users = {};

const usersStore = GeneralStore.define()
  .defineGet(() => users)
  .register(); // the dispatcher instance is set so no need to explicitly pass it

function MyComponent() {
  // no need to pass it to "useStoreDependency" or "connect" either
  const users = GeneralStore.useStoreDependency(usersStore);
  /* ... */
}

Dispatcher Interface

At HubSpot we use the Facebook Dispatcher, but any object that conforms to the same interface (i.e. has register and unregister methods) should work just fine.

type DispatcherPayload = {
  actionType: string,
  data: any,
};

type Dispatcher = {
  isDispatching: () => boolean,
  register: (handleAction: (payload: DispatcherPayload) => void) => string,
  unregister: (dispatchToken: string) => void,
  waitFor: (dispatchTokens: Array<string>) => void,
};

Redux Devtools Extension

Using Redux devtools extension you can inspect the state of a store and see how the state changes between dispatches. The "Jump" (ability to change store state to what it was after a specific dispatch) feature should work but it is dependent on you using regular JS objects as the backing state.

Using the defineFactory way of creating stores is highly recommended for this integration as you can define a name for your store and always for the state of the store to be inspected programmatically.

Build and test

Install Dependencies

# pull in dependencies
yarn install

# run the type checker and unit tests
yarn test

# if all tests pass, run the dev and prod build
yarn run build-and-test

# if all tests pass, run the dev and prod build then commit and push changes
yarn run deploy

Special Thanks

Logo design by Chelsea Bathurst

general-store's People

Contributors

aaylward avatar aem avatar banderson avatar colbyr avatar dependabot[bot] avatar friss avatar gmcnaughton avatar gusvargas avatar henryqdineen avatar kwm4385 avatar marcneuwirth avatar theopak avatar wolfd 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  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

general-store's Issues

type checking the unit tests

"[coming soon] Support for converting existing TypeScript declaration files (.d.ts) for common libraries on DefinitelyTyped.org to Flow declarations." -the flow website

I would really like to have this sooner rather than later, but I guess we'll see.

GeneralStore "cookbook"

I'm imagining a world where we have a sort of "cookbook" to demonstrate how to achieve common (and maybe more advanced) patterns with GeneralStore.

Example might include:

  • ImmutableJS
  • Undo
  • Backbone Models/Collections
  • Atom-ish "swapping"
  • modeling complex relationships with StoreDependencyMixin

Should store data be stored in state?

I've seen some confusion about StoreDependencyMixin data living in state. It's tempting to try to access other fields in the deref function:

StoreDependencyMixin({
  user: UserStore,
  friends: {
    stores: [FriendsStore],
    deref: (props, state) => {
      return FriendsStore.get(this.state.user.id);
    }
  }
});

But its almost always the case that that field is either not set or undefined. There's not really any way to synchronize the updates. The correct solution is for the friends field to depend on both the UserStore and the FriendStore:

StoreDependencyMixin({
  friends: {
    stores: [FriendsStore, UserStore],
    deref: (props, state, [FriendsStore, UserStore]) => {
      return FriendsStore.get(UserStore.get().id);
    }
  }
});

I thought about removing the state param from the deref function entirely, but that seems overly restrictive. State can be useful. Think pagination.

A better solution might be to store the field results on a separate property (think this.data with the observe method).

It certainly comes with tradeoffs. How do we shouldComponentUpdate?

shallowEqual compare does not correctly update its value

@aem

When returning multiple values from a dependency within an object, the shallowEqual incorrectly evaluates whether or not there was a change in value depending on the order and type of these values. If both values are updated, the dependency will recalculate.

// Updating only the bar ImmutableMap below will not trigger a value update for this dependency
{
  foo: [123],
  bar: ImmutableMap({
    baz: "baz"
  }),
}

// Updating only the bar ImmutableMap below will trigger a value update for this dependency
{
  bar: ImmutableMap({
    baz: "baz"
  }),
  foo: [123],
}

React 16 Updates

So now that React 16.3 has landed a few internal methods have changed so general-store will need some changes to stay up to date.

Component methods being deprecated

Other potienally useful things

  • createRef
    • I noticed that we create refs on connect which actually throws a warning if you try to do that on a stateless component. We might be able to use the new ref API to make this more seamless. ๐Ÿคทโ€โ™‚๏ธ
  • context
    • There is the new context api which might be useful for providing the store connections. It looks pretty sweet.
  • suspense
    • Being able to suspend some store subscriptions would be nice for fetching data.

@colbyr @marcneuwirth @Phoenixmatrix @WhoeverElseCaresAboutGeneralStore

GeneralStore.next

Now that we've been using general-store for a few months, I want to think about what's working, what's missing, and how we can improve the API.

  • simplify the mixin: assume 1 dispatcher, 1 mixin/component?
  • "higher order" component alternative to the mixin
  • people find the link between the mixin and component state confusing: maybe the observable API is the answer
  • how does something like reselect fit in
  • FSA compatibility: https://github.com/acdlite/flux-standard-action
  • consider moving react specifics to react-general-store

done

  • composable stores - maybe store state gets passed to the response function - #40

actually lets not

  • getMeta for paging and stuff like that
  • chrome devtools integration

action not propagated to next response

I have two stores defined response to the same action. When the action is triggered, only one response will be called. If I change the called defineResponseTo() to a dummy action, the other one is called.

Is this a limitation in general-store?

var ConfigStore = GeneralStore.define()
  .defineGet(function () {
    return config;
  })
  .defineResponseTo('CONFIG_LOADED', function (data) {
    if (!data.err) {
      config = data.content;
    }
  })
  .register(dispatcher);
var StatusStore = GeneralStore.define()
  .defineGet(function () {
    return status;
  })
  .defineResponseTo('CONFIG_LOADED', function (data) {
    if (!data.err) {
      status.config = 'ready';
    } else {
      status.config = data.err;
    }
  })
  .register(dispatcher);

Publish TS types

The library was converted to TypeScript, but along with that we never started publishing TS type definitions. We should generate and publish TS types with our build process

handle focus when `BaseComponent` is an object

In cases where we useforwardRef, forwardRef returns an object rather than a react class/function. This means that BaseComponent.prototype is undefined and causes the following error:

Screen Shot 2019-04-18 at 10 37 16 AM

Expressing complex store dependencies in the mixin

It would be useful if 1 field could depend on multiple stores so it's possible to avoid waitFors.

For example:

StoreDependencyMixin({
  contacts: {
    store: ContactsStore,
    deref: () => {
      var contacts = ContactsStore.get();
      return CompanyContactIdsStore.get().map(id => contacts.get(id));
    }
  }
})

The contacts field is a function of both the ContactsStore and the ComanyContactIdsStore. In the current state of the world that field can only respond to changes on 1 store.

Convert to ESModules

Currently, the module's entrypoint is a CommonJS module. Given that the rest of the library uses ESM, we should update the entry point to use the same. This will also enable treeshaking for General Store, as many build systems struggle to treeshake CJS modules.

Dedupe store dependency listeners

Using the StoreDependencyMixin if you have two different dependencies on one store...

StoreDependencyMixin({
  users: UserStore,
  numberOfUsers: {
    store: UserStore,
    deref: () => UsersStore.get().count
  }
})

There will be two different listeners. I think this could be a pretty useful pattern so it's a solid optimization.

applying StoreDependencyMixin to a store with a `waitFor` creates a circular dependency

It should be relatively straight forward fix this.

Instead of listening to the store change events, the mixin collects a list of actionTypes from all dependent stores and registers a callback with the dispatcher.

When a dispatch comes through for any of those actionTypes, we waitFor all of the stores, then flush the component queue.

Since we're in a dispatcher callback and not a store callback, we dont trigger a circular dependency error.

batch updates for fields with a common store

Given the following:

React.createClass({
  mixins: [
    GeneralStore.StoreDependencyMixin({
      a: {
        stores: [ExampleStore],
        deref: () => ExampleStore.get().a
      },
      b: {
        stores: [ExampleStore],
        deref: () => ExampleStore.get().b
      }
    })
  ]
  render: function() {
    return <div />;
  }
});

When ExampleStore triggers a change, the mixin will call setState twice causing two renders. It should be pretty straight forward batch the state updates.

window.Immutable

GeneralStore uses window.Immutable in the code.

var compare = window.Immutable && typeof window.Immutable.is === 'function' ? window.Immutable.is : function (a, b) {
  return a === b;
};

This use of window means that general-store cannot be required at all on the server because a ReferenceError is thrown when window is accessed.

Also, when Immutable is loaded as a module it does not define Immutable on window. This means that if general-store and immutable are required by browserified code GeneralStore will not use Immutable.is.

test that the build has been built

I just ran into a problem where I published v0.1.2 but I forgot to run the build so src/ was up to date but build/ still reflected v0.1.1.

Rewrite `connect` to use `useStoreDependency`

This will enable General Store's bundle to be significantly smaller, as many of the object mapping and dependency calculation methods that currently get bundled are only required to support class components and connect.

This will require us to build a version of useStoreDependency that supports multiple dependencies, as React's rules of hooks will not allow us to dynamically create multiple useStoreDependency calls inside of a loop. Fortunately, this is already the default behavior of our dependency calculator, the existing useStoreDependency actually hacks around this behavior, so this should be a relatively easy addition

Improve the build

I'd like to explore webpack for the build and possibly babel or something instead of a random dependency on React for type stripping and ES6 transforms.

Dependent immutable values create infinite loop with useStoreDependency

As reported by @ditkin, if a dependency returns an Immutable object we get sent into an infinite loop because the dependencies aren't strictly equal, something we currently test for in the custom hook:

if (newValue !== dependencyValue) {

Let's convert that usage to use our shallowEqual function and update our shallowEqual function to gracefully handle Immutable objects.

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.