redux-orm
THIS IS A WORK IN PROGRESS!
A small, simple and immutable ORM to manage the entities in your Redux store. redux-orm
doesn't mutate the data, it only returns a new database state.
![npm downloads](https://img.shields.io/npm/dm/redux-orm.svg?style=flat-square)
Features
- Define Models with ES6 classes
- An expressive API to write terse reducers
- Doesn't mutate state. Only returns the next database state.
Why?
I got tired of the boilerplate I was writing for reducers. I wrote long reducers that do pretty much the same thing with very small variations. Immutability helpers make the task easier, but the code is not as expressive since it doesn't implement an abstraction of entities and their relations (state.updateIn([id, 'locations'], [0, 2])
vs Person.objects.get({id}).locations.add(0, 2)
). Hence redux-orm
was born.
import {Schema, fk, many} from 'redux-orm';
const schema = new Schema();
schema.define('Person', {
location: fk('Location')
friends: many('this'),
}, (state, action, Person, orm) => {
switch (action.type) {
case ADD_PERSON:
Person.create(action.payload);
break;
case EDIT_PERSON:
Person.withId(action.payload.id).update(action.payload);
break;
case DELETE_PERSON:
Person.withId(action.payload.id).delete();
break;
case ADD_FRIENDS:
Person.withId(action.payload.id).friends.add(action.payload.friendIds);
break;
case DELETE_FRIENDS:
Person.withId(action.payload.id).friends.remove(action.payload.friendIds);
break;
default:
return state;
}
return Person.getNextState();
});
schema.define('Location');
function rootReducer(state, action) {
entities: schema.reducer(),
}
class App extends React.Component {
render() {
const {entities} = this.props;
const {Person, Location} = schema.from(entities);
const people = Person.filter(person => person.age > 18).toPlain();
const childrenElements = people.map(person => {
return <li>{person.toString()}</li>
});
return <ul>{childrenElements}</ul>;
}
}
The API is heavily inspired by the Django ORM. All edits (delete, create, update, set, orderBy) are deferred to when you call getNextState
on a Model and don't mutate data.
Installation
npm install --save redux-orm
Usage
Import.
import {Schema, fk, many, oneToOne, Model} from 'redux-orm';
Declare your schema.
const schema = new Schema();
const Person = schema.define('Person', {
friends: many('this'),
location: fk('Location'),
}, (state, action, Person, orm) => {
return Person.getNextState();
});
class Person extends Model {
static getMetaOpts() {
return {
name: 'Person',
};
}
static get fields() {
return {
friends: many('this'),
location: fk('Location'),
};
}
static reducer(state, action, Person, session) {
switch (action.type) {
case CREATE_PERSON:
Person.create(action.payload);
break;
case UPDATE_PERSON:
Person.withId(action.payload.id).update(action.payload);
break;
case ADD_LOCATION:
Person.withId(action.payload.id).locations.add(action.payload.locationId);
case REMOVE_LOCATION:
Person.withId(action.payload.id).locations.remove(action.payload.locationId);
case DELETE_PERSON:
Person.withId(action.payload.id).delete();
break;
default:
return state;
}
return Person.getNextState();
}
toString() {
return this.getFullName();
}
getFullName() {
return `${this.first_name} ${this.last_name}`;
},
}
Person.fields = {
friends: many('this'),
location: fk('Location'),
};
class Location extends Model {
toString() {
return `${this.city}, ${this.country}`;
}
}
Plug the reducer anywhere you like. entities
in the root reducer is a good bet.
function rootReducer(state, action) {
entities: schema.reducer(),
}
Now every time you dispatch an action, the Person reducer you defined will be called. Any changes you make to many-to-many relations in the reducer will also be applied. However, you can't (and shouldn't) record mutations for any neighboring Models that are not M2M. They will not be applied. You should do that in the Model's own reducer.
After an action dispatch triggers a store change in redux
, you can instantiate a session in a React component to query the Model instances and use any of the instance methods you declared.
Rationale
If you're storing items in your redux
state tree this way:
const tree = {
people: [0, 1, 2],
peopleById: {
0: {
name: 'Tommi',
age: 25
},
1: {
name: 'John',
age: 35
},
2: {
name: 'Mary',
age: 30
}
}
};
You'll end up writing quite a bit of boilerplate to handle create, update and delete operations, especially if you're doing it in pure JS.
function peopleReducer(state, action) {
switch (action.type) {
case CREATE_PERSON:
return [...state, action.payload.id];
case DELETE_PERSON:
const personIdx = state.indexOf(action.payload);
return [...state.slice(0, personIdx), ...state.slice(personIdx + 1)];
default:
return state;
}
}
function peopleByIdReducer(state, action) {
switch (action.type) {
case CREATE_PERSON:
return {
...state,
[action.payload.id]: omit(action.payload, 'id'),
};
case DELETE_PERSON:
return omit(state, action.payload);
case UPDATE_PERSON:
const prevPerson = state[action.payload.id];
return {
...state,
[action.payload.id]: Object.assign({}, prevPerson, omit(action.payload, 'id')),
};
default:
return state;
}
}
If you have different entity types, you'll be writing a lot of boilerplate. If you have relations that you need to tip-toe with in the reducers, you'll have to write variations of that boilerplate. The bugs are bound to creep in at some point.
Here's the same logic with redux-orm
:
function peopleReducer(state, action, Person) {
switch (action.type) {
case CREATE_PERSON:
Person.create(action.payload);
break;
case UPDATE_PERSON:
Person.withId(action.payload.id).update(action.payload);
break;
case DELETE_PERSON:
Person.withId(action.payload.id).delete();
break;
default:
return state;
}
return Person.getNextState();
}
Now that's terse.
Caveats
The ORM abstraction will never be as performant compared to writing reducers by hand, and adds to the build size of your project. If you have very simple data without relations, redux-orm
may be overkill. The development convenience benefit is considerable though. If you need better performance, you can subclass Meta
which defines the data structure and it's access and update functions. It's not that hard, because the database is just a JavaScript object store.
API
Schema
See the full documentation for Schema here
Model
See the full documentation for Model
here.
import {Model, Schema} from 'redux-orm';
class Person extends Model {
static getMetaOpts() {
return {
name: 'Person';
}
}
toString() {
return `${this.name}, age ${this.age}`;
}
}
const schema = new Schema();
schema.register(Person);
schema.from(startingState);
const person = schema.Person.get({name: 'Tommi'});
person.age;
person.toPlain()
person.toString();
person.set('name', 'Matt');
person.name
person.delete();
person.name
schema.getNextState();
Class Methods:
get
to get a Model instance based on matching properties,create
to create a new Model instance. The new id
will be Math.max(...allOtherIds) + 1
unless you set it explicitly or override the manager's nextId
method.
You will also have access to all QuerySet instance methods from the class object for convenience.
Instance methods:
toPlain
: returns a plain JavaScript object presentation of the Model.set
: marks a supplied propertyName
to be updated to value
at Model.getNextState
. Returns undefined
update
: marks a supplied object of property names and values to be merged with the Model instance at Model.getNextState
. Returns undefined
.delete
: marks the Model instance to be deleted at Model.getNextState
. Returns undefined
.
Meta
See the full documentation for Meta here
Session
See the full documentation for Session here
QuerySet
See the full documentation for QuerySet
here.
You can access all of these methods straight from a Model
class.
Methods:
toPlain
: returns the QuerySet
entities as an array of objects.count
: returns the number of entities in the QuerySet
.exists
: return true
if number of entities is more than 0, else false
.filter
: returns a new QuerySet
with the entities that pass the filter. You can either pass an object that redux-orm
tries to match to the entities, or a function that returns true
if you want to have it in the new QuerySet
, false
if not.exclude
returns a new QuerySet
with the entities that do not pass the filter. Similarly to filter
, you may pass an object for matching (all entities that match will not be in the new QuerySet
) or a function.map
maps through all Model instances.all
returns a new QuerySet
with the same entities.at
returns an Model
instance at the supplied index in the QuerySet
.first
returns an Model
instance at the 0
index.last
returns an Model
instance at the querySet.count() - 1
index.delete
marks all the QuerySet
entities for deletion on Model.getNextState
.update
marks all the QuerySet
entities for an update based on the supplied argument. The argument can either be an object that will be merged with the entity, or a mapping function that takes the entity as an argument and returns a new, updated entity. Do not mutate the entity if you pass a function to update
.
License
MIT. See LICENSE