redux-orm
A small, simple and immutable ORM to manage relational data in your Redux store.
See a a guide to creating a simple app with Redux-ORM (includes the source).
API can be unstable until 1.0.0. Minor version bumps before 1.0.0 can and will introduce breaking changes. They will be noted in the changelog.
Extensions
redux-orm-proptypes
: React PropTypes validation and defaultProps mixin for Redux-ORM Models
Installation
npm install redux-orm --save
Or with a script tag
<script src="https://tommikaikkonen.github.io/redux-orm/dist/redux-orm.js"></script>
Usage
Declare Your Models
You can declare your models with the ES6 class syntax, extending from Model
. You are not required to declare normal fields, only fields that relate to another model. redux-orm
supports one-to-one and many-to-many relations in addition to foreign keys (oneToOne
, many
and fk
imports respectively). Non-related properties can be accessed like in normal JavaScript objects.
// models.js
import {fk, many, Model} from 'redux-orm';
class Book extends Model {
toString() {
return `Book: ${this.name}`;
}
// Declare any static or instance methods you need.
}
Book.modelName = 'Book';
// Declare your related fields.
Book.fields = {
authors: many('Author', 'books'),
publisher: fk('Publisher', 'books'),
};
Write Your Model-Specific Reducers
Every Model
has it's own reducer. It'll be called every time Redux dispatches an action and by default it returns the previous state. You can declare your own reducers inside your models as static reducer()
, or write your reducer elsewhere and connect it to redux-orm
later. The reducer receives the following arguments: the previous state, the current action, the model class connected to the state, and finally the Session
instance. Here's our extended Book model declaration with a reducer:
// models.js
class Book extends Model {
static reducer(state, action, Book) {
switch (action.type) {
case 'CREATE_BOOK':
Book.create(action.payload);
break;
case 'UPDATE_BOOK':
Book.withId(action.payload.id).update(action.payload);
break;
case 'REMOVE_BOOK':
const book = Book.withId(action.payload);
book.delete();
break;
case 'ADD_AUTHOR_TO_BOOK':
Book.withId(action.payload.bookId).authors.add(action.payload.author);
break;
case 'REMOVE_AUTHOR_FROM_BOOK':
Book.withId(action.payload.bookId).authors.remove(action.payload.authorId);
break;
case 'ASSIGN_PUBLISHER':
Book.withId(action.payload.bookId).publisher = action.payload.publisherId;
break;
}
return Book.getNextState();
}
toString() {
return `Book: ${this.name}`;
}
}
// Below we would declare Author and Publisher models
Connect to Redux
To create our data schema, we create a Schema instance and register our models.
// schema.js
import {Schema} from 'redux-orm';
import {Book, Author, Publisher} from './models';
const schema = new Schema();
schema.register(Book, Author, Publisher);
export default schema;
Schema
instances expose a reducer
method that returns a reducer you can use to plug into Redux. Preferably in your root reducer, plug the reducer under a namespace of your choice:
// main.js
import schema from './schema';
import {combineReducers} from 'redux';
const rootReducer = combineReducers({
orm: schema.reducer()
});
Use with React
Use memoized selectors to make queries into the state. redux-orm
uses smart memoization: the below selector accesses Author
and AuthorBooks
branches (AuthorBooks
is a many-to-many branch generated from the model field declarations), and the selector will be recomputed only if those branches change. The accessed branches are resolved on the first run.
// selectors.js
import schema from './schema';
const authorSelector = schema.createSelector(session => {
return session.Author.map(author => {
// Returns a reference to the raw object in the store,
// so it doesn't include any reverse or m2m fields.
const obj = author.ref;
// Object.keys(obj) === ['id', 'name']
return Object.assign({}, obj, {
books: author.books.withRefs.map(book => book.name),
});
});
});
// Will result in something like this when run:
// [
// {
// id: 0,
// name: 'Tommi Kaikkonen',
// books: ['Introduction to redux-orm', 'Developing Redux applications'],
// },
// {
// id: 1,
// name: 'John Doe',
// books: ['John Doe: an Autobiography']
// }
// ]
Selectors created with schema.createSelector
can be used as input to any additional reselect
selectors you want to use. They are also great to use with redux-thunk
: get the whole state with getState()
, pass the ORM branch to the selector, and get your results. A good use case is serializing data to a custom format for a 3rd party API call.
Because selectors are memoized, you can use pure rendering in React for performance gains.
// components.js
import PureComponent from 'react-pure-render/component';
import {authorSelector} from './selectors';
import {connect} from 'react-redux';
class App extends PureComponent {
render() {
const authors = this.props.authors.map(author => {
return (
<li key={author.id}>
{author.name} has written {author.books.join(', ')}
</li>
);
});
return (
<ul>
{authors}
</ul>
);
}
}
function mapStateToProps(state) {
return {
authors: authorSelector(state.orm),
};
}
export default connect(mapStateToProps)(App);
Understanding redux-orm
An ORM?
Well, yeah. redux-orm
deals with related data, structured similar to a relational database. The database in this case is a simple JavaScript object database.
Why?
For simple apps, writing reducers by hand is alright, but when the number of object types you have increases and you need to maintain relations between them, things get hairy. ImmutableJS goes a long way to reduce complexity in your reducers, but redux-orm
is specialized for relational data.
Reducers and Immutability
Say we're inside a reducer and want to update the name of a book.
const book = Book.withId(action.payload.id)
book.name // 'Refactoring'
book.name = 'Clean Code'
book.name // 'Refactoring'
Assigning a new value has no effect on the current state. Behind the scenes, an update to the data was recorded. When you call
Book.getNextState()
// the item we edited will have now values {... name: 'Clean Code'}
the update will be reflected in the new state. The same principle holds true when you're creating new instances and deleting them.
How Updates Work Internally
By default, each Model has the following JavaScript object representation:
{
items: [],
itemsById: {},
}
This representation maintains an array of object ID's and an index of id's for quick access. (A single object array representation is also provided for use. It is possible to subclass Backend
to use any structure you want).
redux-orm
runs a mini-redux inside it. It queues any updates the library user records with action-like objects, and when getNextState
is called, it applies those actions with its internal reducers. Updates within each getNextState
are implemented with batched mutations, so even a big number of updates should be pretty performant.
Customizability
Just like you can extend Model
, you can do the same for QuerySet
(customize methods on Model instance collections) and Backend
(customize store access and updates).
Caveats
The ORM abstraction will never be as performant compared to writing reducers by hand, and adds to the build size of your project (last I checked, minimizing the source files and gzipping yielded about 8 KB). If you have very simple data without relations, redux-orm
may be overkill. The development convenience benefit is considerable though.
API
Schema
See the full documentation for Schema here
Instantiation
const schema = new Schema(); // no arguments needed.
Instance methods:
register(model1, model2, ...modelN)
: registers Model classes to theSchema
instance.define(name, [relatedFields], [backendOpts])
: shortcut to define and register simple models.from(state, [action])
: begins a newSession
withstate
. Ifaction
is omitted, the session can be used to query the state data.reducer()
: returns a reducer function that can be plugged into Redux. The reducer will return the next state of the database given the provided action. You need to register your models before calling this.createSelector([...inputSelectors], selectorFunc)
: returns a memoized selector function forselectorFunc
.selectorFunc
receivessession
as the first argument, followed by any inputs frominputSelectors
. Read the full documentation for details.
Model
See the full documentation for Model
here.
Instantiation: Don't instantiate directly; use class method create
.
Class Methods:
hasId(id)
: returns a boolean indicating if entity with idid
exists in the state.withId(id)
: gets the Model instance with idid
.get(matchObj)
: to get a Model instance based on matching properties inmatchObj
,create(props)
: to create a new Model instance withprops
. If you don't supply an id, the newid
will beMath.max(...allOtherIds) + 1
. You can override thenextId
class method on your model.
You will also have access to almost all QuerySet instance methods from the class object for convenience.
Instance Attributes:
ref
: returns a direct reference to the plain JavaScript object representing the Model instance in the store.
Instance methods:
equals(otherModel)
: returns a boolean indicating equality withotherModel
. Equality is determined by shallow comparison of both model's attributes.set(propertyName, value)
: marks a suppliedpropertyName
to be updated tovalue
atModel.getNextState
. Returnsundefined
. Is equivalent to normal assignment.update(mergeObj)
: marks a supplied object of property names and values (mergeObj
) to be merged with the Model instance atModel.getNextState()
. Returnsundefined
.delete()
: marks the Model instance to be deleted atModel.getNextState()
. Returnsundefined
.
Subclassing:
Use the ES6 syntax to subclass from Model
. Any instance methods you declare will be available on Model instances. Any static methods you declare will be available on the Model class in Sessions.
For the related fields declarations, either set the fields
property on the class or declare a static getter that returns the field declarations like this:
Declaring fields
:
class Book extends Model {
static get fields() {
return {
author: fk('Author')
};
}
}
// alternative:
Book.fields = {
author: fk('Author')
}
All the fields fk
, oneToOne
and many
take a single argument, the related model name. The fields will be available as properties on each Model
instance. You can set related fields with the id value of the related instance, or the related instance itself.
For fk
, you can access the reverse relation through author.bookSet
, where the related name is ${modelName}Set
. Same goes for many
. For oneToOne
, the reverse relation can be accessed by just the model name the field was declared on: author.book
.
For many
field declarations, accessing the field on a Model instance will return a QuerySet
with two additional methods: add
and remove
. They take 1 or more arguments, where the arguments are either Model instances or their id's. Calling these methods records updates that will be reflected in the next state.
When declaring model classes, always remember to set the modelName
property. It needs to be set explicitly, because running your code through a mangler would otherwise break functionality. The modelName
will be used to resolve all related fields.
Declaring backend
:
class Book extends Model {
static get modelName() {
return 'Book';
}
}
// alternative:
Book.modelName = 'Book';
QuerySet
See the full documentation for QuerySet
here.
You can access all of these methods straight from a Model
class, as if they were class methods on Model
. In this case the functions will operate on a QuerySet that includes all the Model instances.
Instance methods:
toRefArray()
: returns the objects represented by theQuerySet
as an array of plain JavaScript objects. The objects are direct references to the store.toModelArray()
: returns the objects represented by theQuerySet
as an array ofModel
instances objects.count()
: returns the number ofModel
instances in theQuerySet
.exists()
: returntrue
if number of entities is more than 0, elsefalse
.filter(filterArg)
: returns a newQuerySet
with the entities that pass the filter. ForfilterArg
, you can either pass an object thatredux-orm
tries to match to the entities, or a function that returnstrue
if you want to have it in the newQuerySet
,false
if not. The function receives a model instance as its sole argument.exclude
returns a newQuerySet
with the entities that do not pass the filter. Similarly tofilter
, you may pass an object for matching (all entities that match will not be in the newQuerySet
) or a function. The function receives a model instance as its sole argument.map(func)
map the entities inQuerySet
, returning a JavaScript array.all()
returns a newQuerySet
with the same entities.at(index)
returns anModel
instance at the suppliedindex
in theQuerySet
.first()
returns anModel
instance at the0
index.last()
returns anModel
instance at thequerySet.count() - 1
index.delete()
marks all theQuerySet
entities for deletion onModel.getNextState
.update(mergeObj)
marks all theQuerySet
entities for an update based on the supplied object. The object will be merged with each entity.
withRefs/withModels flagging
When you want to iterate through all entities with filter
, exclude
, forEach
, map
, or get an item with first
, last
or at
, you don't always need access to the full Model instance - a reference to the plain JavaScript object in the database could do. QuerySets maintain a flag indicating whether these methods operate on plain JavaScript objects (a direct reference from the store) or a Model instances that are instantiated during the operations.
const queryset = Book.withRefs.filter(book => book.author === 'Tommi Kaikkonen')
// `book` is a plain javascript object, `queryset` is a QuerySet
//
const queryset2 = Book.filter(book => book.name === 'Tommi Kaikkonen - An Autobiography')
// `book` is a Model instance. `queryset2` is a QuerySet equivalent to `queryset`.
The flag persists after setting the flag. If you use filter
, exclude
or orderBy
, the returned QuerySet
will have the flag set to operate on Model instances either way. The default is to operate on Model instances. You can get a copy of the current QuerySet
with the flag set to operate on references from the withRefs
attribute. Likewise a QuerySet
copy with the flag set to operate on model instances can be gotten by accessing the withModels
attribute.
queryset.filter(book => book.isReleasedAfterYear(2014))
// The `withRefs` flag was reverted back to using models after the `filter` operation,
// so `book` here is a model instance.
// You rarely need to use `withModels`, unless you're unsure which way the flag is.
queryset2.withRefs.filter(book => book.release_year > 2014)
// `book` is once again a plain JavaScript object, a direct reference from the store.
Session
See the full documentation for Session here
Instantiation: you don't need to do this yourself. Use schema.from
.
Instance properties:
getNextState(opts)
: applies all the recorded updates in the session and returns the next state. You may pass options with theopts
object.reduce()
: calls model-specific reducers and returns the next state.
Additionally, you can access all the registered Models in the schema for querying and updates as properties of this instance. For example, given a schema with Book
and Author
models,
const session = schema.from(state, action);
session.Book // Model class: Book
session.Author // Model class: Author
session.Book.create({id: 5, name: 'Refactoring', release_year: 1999});
Backend
Backend implements the logic and holds the information for Models' underlying data structure. If you want to change how that works, subclass Backend
or implement your own with the same interface, and override your models' getBackendClass
classmethod.
See the full documentation for Backend
here
Instantiation: will be done for you. If you want to specify custom options, you can override the YourModelClass.backend
property with the custom options that will be merged with the defaults. For most cases, the default options work well. They are:
{
idAttribute: 'id',
arrName: 'items',
mapName: 'itemsById',
};
Changelog
Minor changes before 1.0.0 can include breaking changes.
0.8.4
Adds UMD build to partially fix #41. You can now use or try out redux-orm
through a script tag:
<script src="https://tommikaikkonen.github.io/redux-orm/dist/redux-orm.js"></script>
redux-orm.js
will point to the master version of the library; If you need to stick to a version, make a copy or build it yourself.
0.8.3
Fixed bug that mutated the backend options in Model
if you supplied custom ones, see Issue 37. Thanks to @diffcunha for the fix!
0.8.2
Fixed regression in Model.prototype.update
0.8.1
Added babel-runtime to dependencies
0.8.0
Adds batched mutations. This is a big performance improvement. Previously adding 10,000 objects would take 15s, now it takes about 0.5s. Batched mutations are implemented using immutable-ops
internally.
Breaking changes:
-
Removed
indexById
option from Backend. This means that data will always be stored in both an array of id's and a map ofid => entity
, which was the default setting. If you didn't explicitly setindexById
tofalse
, you don't need to change anything. -
Batched mutations brought some internal changes. If you had custom
Backend
orSession
classes, or have overriddenModel.getNextState
, please check out the diff.
0.7.0
Breaking changes:
Model classes that you access in reducers and selectors are now session-specific. Previously the user-defined Model class reference was used for sessions, with a private session
property changing based on the most recently created session. Now Model classes are given a unique dummy subclass for each session. The subclass will be bound to that specific session. This allows multiple sessions to be used at the same time.
You most likely don't need to change anything. The documentation was written with this feature in mind from the start. As long as you've used the model class references given to you in reducers and selectors as arguments (not the reference to the model class you defined), you're fine.
0.6.0
Breaking changes:
- When calling
QuerySet.filter
orQuerySet.exclude
with an object argument, any values of that object that look like aModel
instance (i.e. they have agetId
property that is a function), will be turned into the id of that instance before performing the filtering or excluding.
E.g.
Book.filter({ author: Author.withId(0) });
Is equivalent to
Book.filter({ author: 0 });
0.5.0
Breaking changes:
- Model instance method
equals(otherModel)
now checks if the two model's attributes are shallow equal. Previously, it checked if the id's and model classes are equal. - Session constructor now receives a Schema instance as its first argument, instead of an array of Model classes (this only affects you if you're manually instantiating Sessions with the
new
operator).
Other changes:
- Added
hasId
static method to the Model class. It tests for the existence of the supplied id in the model's state. - Added instance method
getNextState
to the Session class. This enables you to get the next state without running model-reducers. Useful if you're bootstrapping data, writing tests, or otherwise operating on the data outside reducers. You can pass an options object that currently accepts arunReducers
key. It's value indicates if reducers should be run or not. - Improved API documentation.
0.4.0
- Fixed a bug that mutated props passed to Model constructors, which could be a reference from the state. I highly recommend updating from 0.3.1.
- API cleanup, see breaking changes below.
- Calling getNextState is no longer mandatory in your Model reducers. If your reducer returns
undefined
,getNextState
will be called for you.
Breaking changes:
- Removed static methods
Model.setOrder()
andBackend.order
. If you want ordered entities, use the QuerySet instance methodorderBy
. - Added helpful error messages when trying to add a duplicate many-to-many entry (Model.someManyRelated.add(...)), trying to remove an unexisting many-to-many entry (Model.exampleManyRelated.remove(...)), or creating a Model with duplicate many-to-many entry ids (Model.create(...)).
- Removed ability to supply a mapping function to QuerySet instance method
update
. If you need to record updates dynamically based on each entity, iterate through the objects withforEach
and record updates separately:
const authors = publisher.authors;
authors.forEach(author => {
const isAdult = author.age >= 18;
author.update({ isAdult });
})
or use the ability to merge an object with all objects in a QuerySet. Since the update operation is batched for all objects in the QuerySet, it can be more performant with a large amount of entities:
const authors = publisher.authors;
const isAdult = author => author.age >= 18;
const adultAuthors = authors.filter(isAdult);
adultAuthors.update({ isAdult: true });
const youngAuthors = authors.exclude(isAdult);
youngAuthors.update({ isAdult: false });
0.3.1
A descriptive error is now thrown when a reverse field conflicts with another field declaration. For example, the following schema:
class A extends Model {}
A.modelName = 'A';
class B extends Model {}
B.modelName = 'B';
B.fields = {
field1: one('A'),
field2: one('A'),
};
would try to define the reverse field b
on A
twice, throwing an error with an undescriptive message.
0.3.0
Breaking changes:
Model.withId(id)
now throws if object with idid
does not exist in the database.
0.2.0
Includes various bugfixes and improvements.
Breaking changes:
- Replaced
plain
andmodels
instance attributes inQuerySet
withwithRefs
andwithModels
respectively. The attributes return a newQuerySet
instead of modifying the existing one. Aref
alias is also added forwithRefs
, so you can doBook.ref.at(2)
. - After calling
filter
,exclude
ororderBy
method on aQuerySet
instance, thewithRefs
flag is always flipped off so that calling the same methods on the returnedQuerySet
would use model instances in the operations. Previously the flag value remained after calling those methods. .toPlain()
fromQuerySet
is renamed to.toRefArray()
for clarity.- Added
.toModelArray()
method toQuerySet
. - Removed
.objects()
method fromQuerySet
. Use.toRefArray()
or.toModelArray()
instead. - Removed
.toPlain()
method fromModel
, which returned a copy of the Model instance's property values. To replace that,ref
instance getter was added. It returns a reference to the plain JavaScript object in the database. So you can doBook.withId(0).ref
. If you need a copy, you can doObject.assign({}, Book.withId(0).ref)
. - Removed
.fromEmpty()
instance method fromSchema
. - Removed
.setReducer()
instance method fromSchema
. You can just doModelClass.reducer = reducerFunc;
.
License
MIT. See LICENSE