├── .babelrc ├── .eslintrc ├── .gitignore ├── Makefile ├── README.md ├── app ├── actionTypes.js ├── actions.js ├── app.jsx ├── bootstrap.js ├── components.jsx ├── index.js ├── models.js ├── reducers.js ├── selectors.js └── test │ ├── factories.js │ ├── testModels.js │ ├── testSelectors.js │ └── utils.js ├── gulpfile.js ├── index.html ├── package.json ├── screenshot.png └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"], 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "mocha": true 5 | }, 6 | "rules": { 7 | "indent": [2, 4], 8 | "no-unused-vars": 1, 9 | "id-length": 0, 10 | "no-unused-expressions": 0, 11 | "react/no-multi-comp": 0, 12 | }, 13 | "ecmaFeatures": { 14 | "restParams": true, 15 | "experimentalObjectRestSpread": true 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.sublime-project 4 | *.sublime-workspace 5 | .publish 6 | build -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | MOCHA_ARGS=--compilers js:babel-core/register 'app/test/test*.@(js|jsx)' 3 | 4 | deploy: 5 | $(BIN)/gulp deploy 6 | 7 | test: 8 | $(BIN)/mocha $(MOCHA_ARGS) 9 | 10 | test-watch: 11 | $(BIN)/mocha $(MOCHA_ARGS) --watch 12 | 13 | PHONY: deploy test test-watch -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Creating a Simple App with Redux-ORM 2 | 3 | This article shows you how to create and test a simple todo application with some related data using [Redux-ORM](https://github.com/tommikaikkonen/redux-orm). I'll assume you're already familiar with Redux and React. The source for the app is in this repository and you can play around with the [live demo here](http://tommikaikkonen.github.io/redux-orm-primer). 4 | 5 | ![Screenshot of the Example App](https://raw.githubusercontent.com/tommikaikkonen/redux-orm-primer/master/screenshot.png) 6 | 7 | ## Overview of Redux-ORM 8 | 9 | You want your data to be normalized in your Redux store. Now if your data is relational, your state is bound to look like tables in a relational database, because that's the normalized way to represent that kind of data. 10 | 11 | As you write your Redux reducers and selectors, you want to divide them to small functions to create a hierarchy of functions. This makes the functions easy to test and reason about, and is one of the great things about Redux. 12 | 13 | You might have a reducer for a list of entities, lets say restaurants. To store information about restaurants, you have an `items` array of restaurant id's, and an `itemsById` map. In the main restaurant reducer, you delegate to separate `items` and `itemsById` reducers. You create another subreducer inside `itemsById` reducer that applies updates on individual restaurants' attributes. 14 | 15 | That works great until you need to access employee entities related to that restaurant. The state supplied to your restaurant reducers doesn't have information about employees. 16 | 17 | You might end up at some of these solutions to the problem: 18 | 19 | - use `redux-thunk` to query the state with `getState` before dispatching the final action object with all the data needed for the separate reducers to do their job, 20 | - write the reducer / selector logic higher up in the hierarchy or 21 | - pass a larger piece of state to child reducers / selectors as an additional argument. 22 | 23 | You can work with all these, but in my opinion, they get hairy to manage. Your code ends up handling a lot of low-level logic, and the bigger picture gets lost. 24 | 25 | Redux-ORM provides an abstraction over the "relational database" state object. You define a schema through models, and start database sessions by passing a Schema instance the relational database state object. Reducers and selectors are able to respectively update and query the whole database through an immutable ORM API, which makes your code more expressive, readable and manageable. 26 | 27 | In practice, the workflow with Redux-ORM looks like this: 28 | 29 | 1. Declare the relational schema with model classes 30 | 2. Write model-specific reducers to edit data 31 | 3. Write selectors to query data for connected components and actions 32 | 4. Plug the Redux-ORM reducer somewhere in your Redux reducer. 33 | 34 | ## Designing State For Our App 35 | 36 | In our app we have Users, Todos and Tags. Every Todo is associated with a single User. A Todo can have multiple Tags, and a Tag can have multiple Todos. 37 | 38 | The schema looks like this: 39 | 40 | ``` 41 | **User** 42 | - id: integer 43 | - name: string 44 | 45 | **Todo** 46 | - id: integer 47 | - text: string 48 | - done: boolean 49 | - user: foreign key to User 50 | - tags: many-to-many relation with Tag 51 | 52 | **Tag** 53 | - name: string (used as the primary key) 54 | ``` 55 | 56 | Additionally we'll store the selected user id in the Redux state, but outside the relational database. Our Redux state would look something like this: 57 | 58 | ```javascript 59 | { 60 | orm: {/* the relational database state */}, 61 | selectedUserId: 0, 62 | } 63 | ``` 64 | 65 | The following actions are possible: 66 | 67 | - Select a User 68 | - Create a Todo with Tags 69 | - Mark a Todo done 70 | - Add a Tag to a Todo 71 | - Remove a Tag from a Todo 72 | - Delete a Todo 73 | 74 | An app this simple would not normally warrant using an ORM, but it'll do great to get you up to speed. 75 | 76 | ## Defining Models 77 | 78 | To let Redux-ORM know about the structure of our relational data, we create models. They are ES6 classes that extend from Redux-ORM's `Model`, which means that you can define any helper methods or attributes you might need on your models. 79 | 80 | You'll often want to define your own base model for your project that extends from Model, and continue to extend your concrete models from that. The base model can contain, for example, functionality related to async calls and validation which Redux-ORM doesn't provide. The methods will be accessible to you when using the ORM API. This example will use vanilla Model classes. 81 | 82 | Let's begin writing our Models. 83 | 84 | ```javascript 85 | // models.js 86 | import { Model } from 'redux-orm'; 87 | 88 | export class Todo extends Model {} 89 | // Note that the modelName will be used to resolve 90 | // relations! 91 | Todo.modelName = 'Todo'; 92 | 93 | export class Tag extends Model { 94 | } 95 | Tag.modelName = 'Tag'; 96 | Tag.backend = { 97 | idAttribute: 'name'; 98 | }; 99 | 100 | export class User extends Model {} 101 | User.modelName = 'User'; 102 | 103 | ``` 104 | 105 | Just like React components need a `displayName`, models need a `modelName` so that running the code through a mangler won't break functionality. The value in `modelName` will be used to construct the relations. We also pass a `backend` attribute to Tag, where we define the custom setting `idAttribute`, so that we identify tags by its `name` attribute like we defined in the schema. The default `idAttribute` is `id`. 106 | 107 | To declare how these models are related, we specify a static `fields` variable to the model class, where you declare the type of relations (`fk` for many to one, `oneToOne` for one to one, and `many` for many to many). 108 | 109 | ```javascript 110 | // models.js 111 | import { Model, many, fk, Schema } from 'redux-orm'; 112 | 113 | export class Todo extends Model {} 114 | Todo.modelName = 'Todo'; 115 | Todo.fields = { 116 | user: fk('User', 'todos'), 117 | tags: many('Tag', 'todos'), 118 | }; 119 | 120 | export class Tag extends Model {} 121 | Tag.modelName = 'Tag'; 122 | Tag.backend = { 123 | idAttribute: 'name'; 124 | }; 125 | 126 | export class User extends Model {} 127 | User.modelName = 'User'; 128 | ``` 129 | 130 | `many`, `fk` and `oneToOne` field factories take two arguments: the related model's `modelName` and an optional reverse field name. If the reverse field name is not specified, it will be autogenerated (the default way to access Todos related to a user instance would be `user.todoSet`). For example, the declaration 131 | 132 | ```javascript 133 | tags: many('Tag', 'todos'), 134 | ``` 135 | 136 | means that accessing the `todos` property of a Tag model instance returns the set of Todo's that have that tag. The value returned by `tagInstance.todos` is not an array but a `QuerySet` instance, which enables filtering, ordering, editing and reading operations on a set of entities. 137 | 138 | > ### How State Is Managed For Many-To-Many Relations 139 | > 140 | > Redux-ORM internally mimics relational databases and creates a new "table" for many-to-many relations, represented by an internal "through" Model class. 141 | > So when we define this field on a Todo: 142 | > 143 | > ```javascript 144 | > tags: many('Tag', 'todos'), 145 | > ``` 146 | > 147 | > Redux-ORM creates a model named `TodoTags`, that's entities are in this form: 148 | > ```javascript 149 | > { 150 | > id: 0, 151 | > fromTodoId: 0, 152 | > toTagId: 1, 153 | > } 154 | > ``` 155 | 156 | What about non-relational fields like `User.name`, `Todo.text` and `Todo.done` that we defined in our schema plan? You don't need to declare non-relational fields in Redux-ORM. You can assign attributes to Model instances as if they were JavaScript objects. Redux-ORM takes care of "shallow immutability" in the model instances, so assignment to Model instances happens in an immutable fashion, but the immutability of the assigned values is your responsibility. This is only a concern if you assign referential (Object or Array) values to a Model instance and mutate those values. 157 | 158 | > ### Using PropTypes to validate non-relational fields 159 | > 160 | > In non-trivial applications, it's smart to validate non-relational fields, 161 | > just like you validate incoming props on React components. [This gist](https://gist.github.com/tommikaikkonen/45d0d2ff2a5a383bb14d) shows you how to define a Model subclass, `ValidatingModel`, that you can use to define your concrete models. You could use it with the Todo model like this: 162 | > 163 | > ```javascript 164 | > class Todo extends ValidatingModel {} 165 | > // ... 166 | > Todo.propTypes = { 167 | > id: React.PropTypes.number, 168 | > text: React.PropTypes.string.isRequired, 169 | > done: React.PropTypes.boolean.isRequired, 170 | > user: React.PropTypes.oneOf([ 171 | > React.PropTypes.instanceOf(User), 172 | > React.PropTypes.number 173 | > ]).isRequired, 174 | > tags: React.PropTypes.arrayOf([ 175 | > React.PropTypes.instanceOf(Tag), 176 | > React.PropTypes.number 177 | > ]), 178 | > }; 179 | > 180 | > Todo.defaultProps = { 181 | > done: false, 182 | > }; 183 | > ``` 184 | 185 | ## Defining Actions and Action Creators 186 | 187 | First up, let's define our action type constants: 188 | 189 | ```javascript 190 | // actionTypes.js 191 | export const SELECT_USER = 'SELECT_USER'; 192 | export const CREATE_TODO = 'CREATE_TODO'; 193 | export const MARK_DONE = 'MARK_DONE'; 194 | export const ADD_TAG_TO_TODO = 'ADD_TAG_TO_TODO'; 195 | export const REMOVE_TAG_FROM_TODO = 'REMOVE_TAG_FROM_TODO'; 196 | export const DELETE_TODO = 'DELETE_TODO'; 197 | ``` 198 | 199 | And here are our simple action creators. 200 | 201 | ```javascript 202 | // actions.js 203 | import { 204 | SELECT_USER, 205 | CREATE_TODO, 206 | MARK_DONE, 207 | DELETE_TODO, 208 | ADD_TAG_TO_TODO, 209 | REMOVE_TAG_FROM_TODO, 210 | } from './actionTypes'; 211 | 212 | export const selectUser = id => { 213 | return { 214 | type: SELECT_USER, 215 | payload: id, 216 | }; 217 | }; 218 | 219 | export const createTodo = props => { 220 | return { 221 | type: CREATE_TODO, 222 | payload: props, 223 | }; 224 | }; 225 | 226 | export const markDone = id => { 227 | return { 228 | type: MARK_DONE, 229 | payload: id, 230 | }; 231 | }; 232 | 233 | export const deleteTodo = id => { 234 | return { 235 | type: DELETE_TODO, 236 | payload: id, 237 | }; 238 | }; 239 | 240 | export const addTagToTodo = (todo, tag) => { 241 | return { 242 | type: ADD_TAG_TO_TODO, 243 | payload: { 244 | todo, 245 | tag, 246 | }, 247 | }; 248 | }; 249 | 250 | export const removeTagFromTodo = (todo, tag) => { 251 | return { 252 | type: REMOVE_TAG_FROM_TODO, 253 | payload: { 254 | todo, 255 | tag, 256 | }, 257 | }; 258 | }; 259 | 260 | ``` 261 | 262 | Pretty standard stuff. 263 | 264 | ## Writing Reducers 265 | 266 | Redux-ORM uses model-specific reducers to operate on data. You define a static `reducer` method on Model classes, which will receive all Redux-dispatched actions. If you don't define a `reducer`, the default implementation will be used, which calls `.getNextState` on the model class and returns the value. 267 | 268 | First up, we'll write the reducer for the Todo model. Note that when creating a Todo, we want to pass a comma-delimited list of tags in the user interface and create the Tag instances based on that. 269 | 270 | ```javascript 271 | // models.js 272 | import { Schema, Model, many, fk } from 'redux-orm'; 273 | import { 274 | CREATE_TODO, 275 | MARK_DONE, 276 | DELETE_TODO, 277 | ADD_TAG_TO_TODO, 278 | REMOVE_TAG_FROM_TODO, 279 | } from './actionTypes'; 280 | 281 | export class Todo extends Model { 282 | static reducer(state, action, Todo, session) { 283 | const { payload, type } = action; 284 | switch (type) { 285 | case CREATE_TODO: 286 | // Payload includes a comma-delimited string 287 | // of tags, corresponding to the `name` property 288 | // of Tag, which is also its `idAttribute`. 289 | const tagIds = action.payload.tags.split(',').map(str => str.trim()); 290 | 291 | // You can pass an array of ids for many-to-many relations. 292 | // `redux-orm` will create the m2m rows automatically. 293 | const props = Object.assign({}, payload, { tags: tagIds }); 294 | Todo.create(props); 295 | break; 296 | case MARK_DONE: 297 | // withId returns a Model instance. 298 | // Assignment doesn't mutate Model instances. 299 | Todo.withId(payload).set('done', true); 300 | break; 301 | case DELETE_TODO: 302 | Todo.withId(payload).delete(); 303 | break; 304 | case ADD_TAG_TO_TODO: 305 | Todo.withId(payload.todo).tags.add(payload.tag); 306 | break; 307 | case REMOVE_TAG_FROM_TODO: 308 | Todo.withId(payload.todo).tags.remove(payload.tag); 309 | break; 310 | } 311 | 312 | // This call is optional. If the reducer returns `undefined`, 313 | // Redux-ORM will call getNextState for you. 314 | return Todo.getNextState(); 315 | } 316 | } 317 | Todo.modelName = 'Todo'; 318 | Todo.fields = { 319 | tags: many('Tag', 'todos'), 320 | user: fk('User', 'todos'), 321 | }; 322 | 323 | ``` 324 | 325 | All Model reducers receive four arguments: the model state, the current action, the Model class, and the current Redux-ORM Session instance. You usually won't need the Session instance, but you can access and query other models through the Session instance (for example, you can access the Tag model through `session.Tag`), but modifying other model's data is not recommended nor supported. 326 | 327 | Redux-ORM enables you to be pretty expressive in your reducers and not worry about the low level stuff. All of the actions done during a session (`create`, `delete`, `add`, `set`, `update`, `remove`) record updates, which will be applied in the new state returned by `Todo.getNextState()`. 328 | 329 | Here's the reducer for Tag. Our simple app won't have any actions that act on User data, so we won't write a reducer for it. 330 | 331 | ```javascript 332 | // models.js 333 | // ... 334 | export class Tag extends Model { 335 | static reducer(state, action, Tag) { 336 | const { payload, type } = action; 337 | switch (type) { 338 | case CREATE_TODO: 339 | const tags = payload.tags.split(','); 340 | const trimmed = tags.map(name => name.trim()); 341 | trimmed.forEach(name => Tag.create({ name })); 342 | break; 343 | case ADD_TAG_TO_TODO: 344 | // Check if the tag already exists 345 | if (!Tag.filter({ name: payload.tag }).exists()) { 346 | Tag.create({ name: payload.tag }); 347 | } 348 | break; 349 | } 350 | 351 | // Tag.getNextState will be implicitly called for us 352 | } 353 | } 354 | Tag.modelName = 'Tag'; 355 | Tag.backend = { 356 | idAttribute: 'name', 357 | }; 358 | ``` 359 | 360 | Now that we've finished our Model definitions, we create a Schema instance, register our models and export it as the default export of models.js: 361 | 362 | ```javascript 363 | // models.js 364 | // ... model definitions above 365 | export const schema = new Schema(); 366 | schema.register(Todo, Tag, User); 367 | 368 | export default schema; 369 | ``` 370 | 371 | We'll use the `schema` instance later to create selectors and inject the ORM reducer to Redux. 372 | 373 | We have one additional reducer outside Redux-ORM we need to define, `selectedUserIdReducer`. We'll put it in `reducers.js`. It's very simple as it maintains a single number corresponding to the selected User id. 374 | 375 | ```javascript 376 | // reducers.js 377 | import { SELECT_USER } from './actionTypes'; 378 | 379 | export function selectedUserIdReducer(state = 0, action) { 380 | const { type, payload } = action; 381 | switch (type) { 382 | case SELECT_USER: 383 | return payload; 384 | default: 385 | return state; 386 | } 387 | } 388 | 389 | ``` 390 | 391 | ## Writing Selectors 392 | 393 | `redux` and `reselect` libraries fit together with the [*Command Query Responsibility Segregation* pattern](http://martinfowler.com/bliki/CQRS.html). The gist of it is that you use a different model to update data (reducers in Redux) and to read data (selectors in Redux). We've handled the updating part of our state management. We're yet to define our selectors. 394 | 395 | First, let's think about what we want to pass to our React components. 396 | 397 | - Since we want to show the name of the current user, and bind action creators with the currently selected user's id, we need an object that describes the currently selected user with an id and a name. We'll call this selector `user`. 398 | - We want to show a list of todos for the selected user. Each todo in that list should have its id, text, status (done or not), and a list of tag names associated with that todo, so we can render it nicely. We'll call this selector `todos`. 399 | - Since we want the end user to be able to select which User to show todos for, we need a list of users you can select, with an id and the name for each user. We'll call this selector `users`. 400 | 401 | Here's selectors.js, where we implement those selectors: 402 | 403 | ```javascript 404 | // selectors.js 405 | import { schema } from './models'; 406 | import { createSelector } from 'reselect'; 407 | 408 | // Selects the state managed by Redux-ORM. 409 | export const ormSelector = state => state.orm; 410 | 411 | // Redux-ORM selectors work with reselect. To feed input 412 | // selectors to a Redux-ORM selector, we use the reselect `createSelector` function. 413 | export const todos = createSelector( 414 | // The first input selector should always be the orm selector. 415 | // Behind the scenes, `schema.createSelector` begins a Redux-ORM 416 | // session with the value returned by `ormSelector` and passes 417 | // that Session instance as an argument instead. 418 | // So `orm` below is a Session instance. 419 | ormSelector, 420 | state => state.selectedUserId, 421 | schema.createSelector((orm, userId) => { 422 | console.log('Running todos selector'); 423 | 424 | // We could also do orm.User.withId(userId).todos.map(...) 425 | // but this saves a query on the User table. 426 | // 427 | // `.withRefs` means that the next operation (in this case filter) 428 | // will use direct references from the state instead of Model instances. 429 | // If you don't need any Model instance methods, you should use withRefs. 430 | return orm.Todo.withRefs.filter({ user: userId }).map(todo => { 431 | // `todo.ref` is a direct reference to the state, 432 | // so we need to be careful not to mutate it. 433 | // 434 | // We want to add a denormalized `tags` attribute 435 | // to each of our todos, so we make a shallow copy of `todo.ref`. 436 | const obj = Object.assign({}, todo.ref); 437 | obj.tags = todo.tags.withRefs.map(tag => tag.name); 438 | 439 | return obj; 440 | }); 441 | }) 442 | ); 443 | 444 | export const user = createSelector( 445 | ormSelector, 446 | state => state.selectedUserId, 447 | schema.createSelector((orm, selectedUserId) => { 448 | console.log('Running user selector'); 449 | // .ref returns a reference to the plain 450 | // JavaScript object in the store. 451 | // It includes the id and name that we need. 452 | return orm.User.withId(selectedUserId).ref; 453 | }) 454 | ); 455 | 456 | export const users = createSelector( 457 | ormSelector, 458 | schema.createSelector(orm => { 459 | console.log('Running users selector'); 460 | 461 | // `.toRefArray` returns a new Array that includes 462 | // direct references to each User object in the state. 463 | return orm.User.all().toRefArray(); 464 | }) 465 | ); 466 | ``` 467 | 468 | Like `reselect` selectors, Redux-ORM selectors have memoization, but it works a little differently. `reselect` checks if the passed arguments match the previous ones, and if so, returns the previous result. Redux-ORM selectors always take the whole `orm` branch as the first argument, therefore `reselect` style memoization would only work when the whole database has not changed. 469 | 470 | To solve this, Redux-ORM observes which models' states you actually accessed when you ran the `todos` selector for the first time. On consecutive calls, before running the selector, the memoize function checks if any of those models' state has changed - if so, the selector is run again. Otherwise the previous result is returned. 471 | 472 | Redux-ORM selectors are compatible with `reselect` selectors, as they are `reselect` selectors with a schema-specific memoization function. 473 | 474 | > ### How Redux-ORM Selector Memoization Works 475 | > 476 | > You don't need to know this to use Redux-ORM, but in case you're interested, here are the step-by-step instructions Redux-ORM uses to memoize selectors. 477 | > 478 | > 1. Has the selector been run before? If not, go to 5. 479 | > 2. If the selector has other input selectors in addition to the ORM state selector, check their results for equality with the previous results. If they aren't equal, go to 5. 480 | > 3. Is the ORM state referentially equal to the previous ORM state the selector was called with? If yes, return the previous result. 481 | > 4. Check which Model's states the selector has accessed on previous runs. Check for equality with each of those states versus their states in the previous ORM state. If all of them are equal, return the previous result. 482 | > 5. Run the selector. Check the Session object used by the selector for which Model's states were accessed, and merge them with the previously saved information about accessed models (if-else branching can change which models are accessed on different inputs). Save the ORM state and other arguments the selector was called with, overriding previously saved values. Save the selector result. Return the selector result. 483 | 484 | So for `todos` selector, we access the Todo and Tag models (because we access all todos with `Todo.map(todo => ...)` and the related tags with `todo.tags`). The `user` selector accesses only the User model. All of our actions modify either the Todo or Tag model states, or change the selected user, so the `todos` selector is run after every update. If we also had a Tag selector like this: 485 | 486 | ```javascript 487 | const tagNames = schema.createSelector(orm => { 488 | return orm.Tag.map(tag => tag.name); 489 | }); 490 | ``` 491 | 492 | it would only get run after the actions `createTodo`, `addTagToTodo` and `removeTagFromTodo` that possibly change the Tag model state. 493 | 494 | When possible, we return plain objects and direct references from the state from selectors. It is much easier to use pure React components by passing plain JavaScript to props -- with Model instances, you would have to override `shouldComponentUpdate` to avoid redundant renders. 495 | 496 | ## Creating React Components 497 | 498 | Next up, let's create our application's main React component that uses the action creators we defined and receives the views we defined through selectors as props. 499 | 500 | ```jsx 501 | // app.js 502 | /* eslint-disable no-shadow */ 503 | import React, { PropTypes } from 'react'; 504 | import PureComponent from 'react-pure-render/component'; 505 | import { connect } from 'react-redux'; 506 | 507 | // Dumb components, their implementation is not relevant 508 | // to this article. 509 | import { 510 | TodoItem, 511 | AddTodoForm, 512 | UserSelector, 513 | } from './components'; 514 | 515 | import { 516 | selectUser, 517 | createTodo, 518 | markDone, 519 | deleteTodo, 520 | addTagToTodo, 521 | removeTagFromTodo, 522 | } from './actions'; 523 | import { 524 | todos, 525 | user, 526 | users, 527 | } from './selectors'; 528 | 529 | class App extends PureComponent { 530 | render() { 531 | const props = this.props; 532 | 533 | const { 534 | todos, 535 | users, 536 | selectedUser, 537 | 538 | selectUser, 539 | createTodo, 540 | markDone, 541 | deleteTodo, 542 | addTagToTodo, 543 | removeTagFromTodo, 544 | } = props; 545 | 546 | console.log('Props received by App component:', props); 547 | 548 | const todoItems = todos.map(todo => { 549 | return ( 550 | // TodoItem is a dumb component, it's implementation 551 | // is not in this article's interest. 552 | 559 | {todo.text} 560 | 561 | ); 562 | }); 563 | 564 | const userChoices = users.map(user => { 565 | return ; 566 | }); 567 | 568 | const onUserSelect = userId => { 569 | selectUser(userId); 570 | }; 571 | 572 | const onCreate = ({ text, tags }) => createTodo({ text, tags, user: selectedUser.id}); 573 | 574 | return ( 575 |
576 |

Todos for {selectedUser.name}

577 | 578 | {userChoices} 579 | 580 | 583 |

Add Todo for {selectedUser.name}

584 | 585 |
586 | ); 587 | } 588 | } 589 | 590 | App.propTypes = { 591 | todos: PropTypes.arrayOf(PropTypes.object).isRequired, 592 | users: PropTypes.arrayOf(PropTypes.object).isRequired, 593 | selectedUser: PropTypes.object.isRequired, 594 | 595 | selectUser: PropTypes.func.isRequired, 596 | createTodo: PropTypes.func.isRequired, 597 | markDone: PropTypes.func.isRequired, 598 | deleteTodo: PropTypes.func.isRequired, 599 | addTagToTodo: PropTypes.func.isRequired, 600 | removeTagFromTodo: PropTypes.func.isRequired, 601 | }; 602 | 603 | // This function takes the Redux state, runs the 604 | // selectors and returns the props passed to App. 605 | function stateToProps(state) { 606 | return { 607 | todos: todos(state), 608 | selectedUser: user(state), 609 | users: users(state), 610 | }; 611 | } 612 | 613 | // This maps our action creators to props and binds 614 | // them to dispatch. 615 | const dispatchToProps = { 616 | selectUser, 617 | createTodo, 618 | markDone, 619 | deleteTodo, 620 | addTagToTodo, 621 | removeTagFromTodo, 622 | }; 623 | 624 | export default connect(stateToProps, dispatchToProps)(App); 625 | 626 | ``` 627 | 628 | ## Bootstrapping Initial State 629 | 630 | To create an initial state for our app, we can manually instantiate an ORM session. To construct the initial state, we don't need immutable operations. We can do that with a mutating session. 631 | 632 | In the model reducers and selectors, a session was instantiated for us automatically. You can instantiate a mutating session by calling the `withMutations` method on a Schema instance, and an immutable one with the `from` method. Both methods take a state object as the required first argument, and optionally an action object as the second argument. 633 | 634 | Let's define a bootstrap function in bootstrap.js that initializes our state with some simple data. 635 | 636 | ```javascript 637 | // bootstrap.js 638 | export default function bootstrap(schema) { 639 | // Get the empty state according to our schema. 640 | const state = schema.getDefaultState(); 641 | 642 | // Begin a mutating session with that state. 643 | // `state` will be mutated. 644 | const session = schema.withMutations(state); 645 | 646 | // Model classes are available as properties of the 647 | // Session instance. 648 | const { Todo, Tag, User } = session; 649 | 650 | // Start by creating entities whose props are not dependent 651 | // on others. 652 | const user = User.create({ 653 | id: 0, // optional. If omitted, Redux-ORM uses a number sequence starting from 0. 654 | name: 'Tommi', 655 | }); 656 | const otherUser = User.create({ 657 | id: 1, // optional. 658 | name: 'John', 659 | }); 660 | 661 | // Tags to start with. 662 | const work = Tag.create({ name: 'work' }); 663 | const personal = Tag.create({ name: 'personal' }); 664 | const urgent = Tag.create({ name: 'urgent' }); 665 | const chore = Tag.create({ name: 'chore' }); 666 | 667 | // Todo's for `user` 668 | Todo.create({ 669 | text: 'Buy groceries', 670 | done: false, 671 | user, 672 | tags: [personal], // We could also pass ids instead of the Tag instances. 673 | }); 674 | Todo.create({ 675 | text: 'Attend meeting', 676 | done: false, 677 | user, 678 | tags: [work], 679 | }); 680 | Todo.create({ 681 | text: 'Pay bills', 682 | done: false, 683 | user, 684 | tags: [personal, urgent], 685 | }); 686 | 687 | // Todo's for `otherUser` 688 | Todo.create({ 689 | text: 'Prepare meals for the week', 690 | done: false, 691 | user: otherUser, 692 | tags: [personal, chore], 693 | }); 694 | Todo.create({ 695 | text: 'Fix the washing machine', 696 | done: false, 697 | user: otherUser, 698 | tags: [personal, chore], 699 | }); 700 | Todo.create({ 701 | text: 'Negotiate internet subscription', 702 | done: false, 703 | user: otherUser, 704 | tags: [personal, urgent], 705 | }); 706 | 707 | // Return the whole Redux initial state. 708 | return { 709 | orm: state, 710 | selectedUserId: 0, 711 | }; 712 | } 713 | 714 | ``` 715 | 716 | Then we can feed that state to Redux. Schema instances have a `reducer` method that returns a reducer function you can plug in to a root reducer. A common way to do that is to have an `orm` branch in your state. Here's the index.js file that brings the whole app together. 717 | 718 | ```jsx 719 | // index.js 720 | import React from 'react'; 721 | import ReactDOM from 'react-dom'; 722 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 723 | import { Provider } from 'react-redux'; 724 | import createLogger from 'redux-logger'; 725 | import { schema } from './models'; 726 | import { selectedUserIdReducer } from './reducers'; 727 | import bootstrap from './bootstrap'; 728 | import App from './app'; 729 | 730 | const rootReducer = combineReducers({ 731 | orm: schema.reducer(), 732 | selectedUserId: selectedUserIdReducer, 733 | }); 734 | 735 | // We're using `redux-logger`. Open up the console in the demo and you can inspect 736 | // the internal state maintained by Redux-ORM. 737 | const createStoreWithMiddleware = applyMiddleware(createLogger())(createStore); 738 | 739 | const store = createStoreWithMiddleware(rootReducer, bootstrap(schema)); 740 | 741 | function main() { 742 | // In the repo, we have a simple index.html that includes Bootstrap CSS files 743 | // and a div element with id `app`. 744 | const app = document.getElementById('app'); 745 | ReactDOM.render(( 746 | 747 | 748 | 749 | ), app); 750 | } 751 | 752 | main(); 753 | 754 | ``` 755 | 756 | ## Testing Reducers and Selectors 757 | 758 | We want to test that we update our state correctly based on action objects. No problem. Redux-ORM makes testing easier too. 759 | 760 | For good integration testing, we want to have nice test data and have the ability to easily generate it. Using an adapter, we can utilize the [`factory-girl`](https://github.com/aexmachina/factory-girl) library to generate data for us (this is overkill for a Todo app, but it is handy in larger projects). You could also use the function we wrote in bootstrap.js to generate test data. 761 | 762 | Here's test/factories.js: 763 | 764 | ```javascript 765 | // test/factories.js 766 | import _factory from 'factory-girl'; 767 | import { Todo, User, Tag } from '../models'; 768 | import bluebird from 'bluebird'; 769 | import { ReduxORMAdapter } from './utils'; 770 | 771 | // factory-girl only works asynchronously with associated models, 772 | // so we need to roll with that even though Redux-ORM is synchronous. 773 | // We promisify factory-girl so we can use Promises instead of callbacks. 774 | const factory = _factory.promisify(bluebird); 775 | 776 | // Use a simple adapter for Redux-ORM 777 | factory.setAdapter(new ReduxORMAdapter()); 778 | 779 | factory.define('Todo', Todo, { 780 | id: factory.sequence(n => n), 781 | text: factory.sequence(n => `Todo ${n}`), 782 | user: factory.assoc('User', 'id'), 783 | tags: factory.assocMany('Tag', 'name', 4), // 4 tags for each Todo 784 | done: factory.sequence(n => n % 2 ? true : false), 785 | }); 786 | 787 | factory.define('User', User, { 788 | id: factory.sequence(n => n), 789 | name: factory.sequence(n => `User ${n}`), 790 | }); 791 | 792 | factory.define('Tag', Tag, { 793 | name: factory.sequence(n => `Tag ${n}`), 794 | }); 795 | 796 | export default factory; 797 | 798 | ``` 799 | 800 | Now we can use the factories to generate some initial data. In our model test suite's `beforeEach`, we start a mutating session, so that `factory-girl` generates the data to a mutating state object. (*Model classes are currently singletons that can be connected to zero or one sessions at a time, so this works even though `factory-girl` knows only about our Model classes and not our Session instance. The Model classes may not be singletons in the future, so I recommend always getting your Model classes from the Session instance*) 801 | 802 | ```javascript 803 | /* eslint no-unused-expressions: 0 */ 804 | import { expect } from 'chai'; 805 | import { 806 | CREATE_TODO, 807 | MARK_DONE, 808 | DELETE_TODO, 809 | ADD_TAG_TO_TODO, 810 | REMOVE_TAG_FROM_TODO, 811 | } from '../actionTypes'; 812 | import { schema } from '../models'; 813 | import factory from './factories'; 814 | import Promise from 'bluebird'; 815 | import { applyActionAndGetNextSession } from './utils'; 816 | 817 | describe('Models', () => { 818 | // This will be the initial ORM state. 819 | let state; 820 | 821 | // This will be a Session instance with the initial data. 822 | let session; 823 | 824 | beforeEach(done => { 825 | // Get the default state and start a mutating session. 826 | // Before we start another session, all Model classes 827 | // will be bound to this session. 828 | state = schema.getDefaultState(); 829 | session = schema.withMutations(state); 830 | 831 | factory.createMany('User', 2).then(users => { 832 | return Promise.all(users.map(user => { 833 | const userId = user.getId(); 834 | 835 | // Create 10 todos for both of our 2 users. 836 | return factory.createMany('Todo', { user: userId }, 10); 837 | })); 838 | }).then(() => { 839 | // Generating data is finished, start up an immutable session. 840 | session = schema.from(state); 841 | 842 | // Let mocha know we're done setting up. 843 | done(); 844 | }); 845 | }); 846 | 847 | it('correctly handle CREATE_TODO', () => { 848 | const todoText = 'New Todo Text!'; 849 | const todoTags = 'testing, nice, cool'; 850 | const user = session.User.first(); 851 | const userId = user.getId(); 852 | 853 | const action = { 854 | type: CREATE_TODO, 855 | payload: { 856 | text: todoText, 857 | tags: todoTags, 858 | user: userId, 859 | }, 860 | }; 861 | 862 | expect(user.todos.count()).to.equal(10); 863 | expect(session.Todo.count()).to.equal(20); 864 | expect(session.Tag.count()).to.equal(80); 865 | 866 | // The below helper function completes an action dispatch 867 | // loop with the given state and action. 868 | // Finally, it returns a new Session started with the 869 | // next state yielded from the dispatch loop. 870 | // With this new Session, we can query the resulting state. 871 | const { 872 | Todo, 873 | Tag, 874 | User, 875 | } = applyActionAndGetNextSession(schema, state, action); 876 | 877 | expect(User.withId(userId).todos.count()).to.equal(11); 878 | expect(Todo.count()).to.equal(21); 879 | expect(Tag.count()).to.equal(83); 880 | 881 | const newTodo = Todo.last(); 882 | 883 | expect(newTodo.text).to.equal(todoText); 884 | expect(newTodo.user.getId()).to.equal(userId); 885 | expect(newTodo.done).to.be.false; 886 | expect(newTodo.tags.map(tag => tag.name)).to.deep.equal(['testing', 'nice', 'cool']); 887 | }); 888 | 889 | // To see tests for the rest of the actions, check out the source. 890 | }); 891 | ``` 892 | 893 | We can use a similar `beforeEach` setup with selectors, although we'll construct the whole Redux state that they take as an input instead of just the ORM state (this includes the selected user id): 894 | 895 | ```javascript 896 | import { expect } from 'chai'; 897 | import { 898 | users, 899 | user, 900 | todos, 901 | } from '../selectors'; 902 | import { schema } from '../models'; 903 | import factory from './factories'; 904 | import Promise from 'bluebird'; 905 | import { applyActionAndGetNextSession } from './utils'; 906 | 907 | describe('Selectors', () => { 908 | let ormState; 909 | let session; 910 | let state; 911 | 912 | beforeEach(done => { 913 | ormState = schema.getDefaultState(); 914 | 915 | session = schema.withMutations(ormState); 916 | 917 | factory.createMany('User', 2).then(users => { 918 | return Promise.all(users.map(user => { 919 | const userId = user.getId(); 920 | 921 | return factory.createMany('Todo', { user: userId }, 10); 922 | })); 923 | }).then(() => { 924 | session = schema.from(ormState); 925 | 926 | state = { 927 | orm: ormState, 928 | selectedUserId: session.User.first().getId(), 929 | }; 930 | 931 | done(); 932 | }); 933 | }); 934 | 935 | it('users works', () => { 936 | const result = users(state); 937 | 938 | expect(result).to.have.length(2); 939 | expect(result[0]).to.contain.all.keys(['id', 'name']); 940 | }); 941 | 942 | it('todos works', () => { 943 | const result = todos(state); 944 | 945 | expect(result).to.have.length(10); 946 | expect(result[0]).to.contain.all.keys(['id', 'text', 'user', 'tags', 'done']); 947 | expect(result[0].tags).to.have.length(4); 948 | }); 949 | 950 | it('user works', () => { 951 | const result = user(state); 952 | expect(result).to.be.an('object'); 953 | expect(result).to.contain.all.keys(['id', 'name']); 954 | }); 955 | }); 956 | 957 | ``` 958 | 959 | And that's it! Play around with the [live demo](https://tommikaikkonen.github.io/redux-orm-primer) and remember to open up the console. 960 | 961 | Ping me on Twitter ([@tommikaikkonen](https://twitter.com/tommikaikkonen)) if you have any questions, or open up an issue in the [redux-orm repository](https://github.com/tommikaikkonen/redux-orm). -------------------------------------------------------------------------------- /app/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const SELECT_USER = 'SELECT_USER'; 2 | export const CREATE_TODO = 'CREATE_TODO'; 3 | export const MARK_DONE = 'MARK_DONE'; 4 | export const DELETE_TODO = 'DELETE_TODO'; 5 | export const ADD_TAG_TO_TODO = 'ADD_TAG_TO_TODO'; 6 | export const REMOVE_TAG_FROM_TODO = 'REMOVE_TAG_FROM_TODO'; 7 | -------------------------------------------------------------------------------- /app/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | SELECT_USER, 3 | CREATE_TODO, 4 | MARK_DONE, 5 | DELETE_TODO, 6 | ADD_TAG_TO_TODO, 7 | REMOVE_TAG_FROM_TODO, 8 | } from './actionTypes'; 9 | 10 | export const selectUser = id => { 11 | return { 12 | type: SELECT_USER, 13 | payload: id, 14 | }; 15 | }; 16 | 17 | export const createTodo = props => { 18 | return { 19 | type: CREATE_TODO, 20 | payload: props, 21 | }; 22 | }; 23 | 24 | export const markDone = id => { 25 | return { 26 | type: MARK_DONE, 27 | payload: id, 28 | }; 29 | }; 30 | 31 | export const deleteTodo = id => { 32 | return { 33 | type: DELETE_TODO, 34 | payload: id, 35 | }; 36 | }; 37 | 38 | export const addTagToTodo = (todo, tag) => { 39 | return { 40 | type: ADD_TAG_TO_TODO, 41 | payload: { 42 | todo, 43 | tag, 44 | }, 45 | }; 46 | }; 47 | 48 | export const removeTagFromTodo = (todo, tag) => { 49 | return { 50 | type: REMOVE_TAG_FROM_TODO, 51 | payload: { 52 | todo, 53 | tag, 54 | }, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /app/app.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | import React, { PropTypes } from 'react'; 3 | import PureComponent from 'react-pure-render/component'; 4 | import { connect } from 'react-redux'; 5 | import { 6 | TodoItem, 7 | AddTodoForm, 8 | UserSelector, 9 | } from './components'; 10 | import { 11 | selectUser, 12 | createTodo, 13 | markDone, 14 | deleteTodo, 15 | addTagToTodo, 16 | removeTagFromTodo, 17 | } from './actions'; 18 | import { 19 | todos, 20 | user, 21 | users, 22 | } from './selectors'; 23 | 24 | class App extends PureComponent { 25 | render() { 26 | const props = this.props; 27 | 28 | const { 29 | todos, 30 | users, 31 | selectedUser, 32 | 33 | selectUser, 34 | createTodo, 35 | markDone, 36 | deleteTodo, 37 | addTagToTodo, 38 | removeTagFromTodo, 39 | } = props; 40 | 41 | console.log('Props received by App component:', props); 42 | 43 | const todoItems = todos.map(todo => { 44 | return ( 45 | 52 | {todo.text} 53 | 54 | ); 55 | }); 56 | 57 | const userChoices = users.map(user => { 58 | return ; 59 | }); 60 | 61 | const onUserSelect = userId => { 62 | selectUser(userId); 63 | }; 64 | 65 | const onCreate = ({ text, tags }) => createTodo({ text, tags, user: selectedUser.id}); 66 | 67 | return ( 68 |
69 |

Todos for {selectedUser.name}

70 | 71 | {userChoices} 72 | 73 | 76 |

Add Todo for {selectedUser.name}

77 | 78 |
79 | ); 80 | } 81 | } 82 | 83 | App.propTypes = { 84 | todos: PropTypes.arrayOf(PropTypes.object).isRequired, 85 | users: PropTypes.arrayOf(PropTypes.object).isRequired, 86 | selectedUser: PropTypes.object.isRequired, 87 | 88 | selectUser: PropTypes.func.isRequired, 89 | createTodo: PropTypes.func.isRequired, 90 | markDone: PropTypes.func.isRequired, 91 | deleteTodo: PropTypes.func.isRequired, 92 | addTagToTodo: PropTypes.func.isRequired, 93 | removeTagFromTodo: PropTypes.func.isRequired, 94 | }; 95 | 96 | // This function takes the Redux state, runs the 97 | // selectors and returns the props passed to App. 98 | function stateToProps(state) { 99 | return { 100 | todos: todos(state), 101 | selectedUser: user(state), 102 | users: users(state), 103 | }; 104 | } 105 | 106 | // This maps our action creators to props and binds 107 | // them to dispatch. 108 | const dispatchToProps = { 109 | selectUser, 110 | createTodo, 111 | markDone, 112 | deleteTodo, 113 | addTagToTodo, 114 | removeTagFromTodo, 115 | }; 116 | 117 | export default connect(stateToProps, dispatchToProps)(App); 118 | -------------------------------------------------------------------------------- /app/bootstrap.js: -------------------------------------------------------------------------------- 1 | export default function bootstrap(schema) { 2 | // Get the empty state according to our schema. 3 | const state = schema.getDefaultState(); 4 | 5 | // Begin a mutating session with that state. 6 | // `state` will be mutated. 7 | const session = schema.withMutations(state); 8 | 9 | // Model classes are available as properties of the 10 | // Session instance. 11 | const { Todo, Tag, User } = session; 12 | 13 | // Start by creating entities whose props are not dependent 14 | // on others. 15 | const user = User.create({ 16 | id: 0, // optional. If omitted, Redux-ORM uses a number sequence starting from 0. 17 | name: 'Tommi', 18 | }); 19 | const otherUser = User.create({ 20 | id: 1, // optional. 21 | name: 'John', 22 | }); 23 | 24 | // Tags to start with. 25 | const work = Tag.create({ name: 'work' }); 26 | const personal = Tag.create({ name: 'personal' }); 27 | const urgent = Tag.create({ name: 'urgent' }); 28 | const chore = Tag.create({ name: 'chore' }); 29 | 30 | // Todo's for `user` 31 | Todo.create({ 32 | text: 'Buy groceries', 33 | user, 34 | tags: [personal], // We could also pass ids instead of the Tag instances. 35 | }); 36 | Todo.create({ 37 | text: 'Attend meeting', 38 | user, 39 | tags: [work], 40 | }); 41 | Todo.create({ 42 | text: 'Pay bills', 43 | user, 44 | tags: [personal, urgent], 45 | }); 46 | 47 | // Todo's for `otherUser` 48 | Todo.create({ 49 | text: 'Prepare meals for the week', 50 | user: otherUser, 51 | tags: [personal, chore], 52 | }); 53 | Todo.create({ 54 | text: 'Fix the washing machine', 55 | user: otherUser, 56 | tags: [personal, chore], 57 | }); 58 | Todo.create({ 59 | text: 'Negotiate internet subscription', 60 | user: otherUser, 61 | tags: [personal, urgent], 62 | }); 63 | 64 | // Return the whole Redux initial state. 65 | return { 66 | orm: state, 67 | selectedUserId: 0, 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /app/components.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import PureComponent from 'react-pure-render/component'; 3 | 4 | export class Tag extends PureComponent { 5 | render() { 6 | const props = this.props; 7 | 8 | const key = typeof props.children === 'string' 9 | ? props.children 10 | : props.children.toString(); 11 | 12 | return ( 13 | 14 | 17 | {props.children}  18 | 19 |   20 | 21 | ); 22 | } 23 | } 24 | 25 | Tag.propTypes = { 26 | onClick: PropTypes.func.isRequired, 27 | children: PropTypes.node.isRequired, 28 | }; 29 | 30 | export class TextSubmitter extends PureComponent { 31 | render() { 32 | const props = this.props; 33 | let inputRef; 34 | 35 | const onClick = () => props.onSubmit(inputRef.value); 36 | 37 | return ( 38 |
39 |
40 | inputRef = el}/> 41 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | TextSubmitter.propTypes = { 49 | text: PropTypes.string.isRequired, 50 | onSubmit: PropTypes.func.isRequired, 51 | }; 52 | 53 | export class TodoItem extends PureComponent { 54 | render() { 55 | const props = this.props; 56 | const tags = props.tags.map(tagName => { 57 | return ( 58 | 59 | {tagName} 60 | 61 | ); 62 | }); 63 | 64 | const text = props.done 65 | ? {props.children} 66 | : {props.children}; 67 | 68 | let listItemClasses = 'list-group-item'; 69 | 70 | if (props.done) listItemClasses += ' disabled'; 71 | 72 | const markDoneButton = props.done 73 | ? null 74 | : ; 75 | 76 | const addTagForm = ; 77 | 78 | return ( 79 |
  • 80 |
    81 |
    82 |

    {text} {tags}

    83 |
    84 |
    85 |

    86 | {markDoneButton}  87 | 91 |

    92 | {addTagForm} 93 |
    94 |
    95 |
  • 96 | ); 97 | } 98 | } 99 | 100 | TodoItem.propTypes = { 101 | children: PropTypes.string.isRequired, 102 | tags: PropTypes.arrayOf(PropTypes.string).isRequired, 103 | done: PropTypes.bool.isRequired, 104 | onAddTag: PropTypes.func.isRequired, 105 | onRemoveTag: PropTypes.func.isRequired, 106 | onMarkDone: PropTypes.func.isRequired, 107 | onDelete: PropTypes.func.isRequired, 108 | }; 109 | 110 | export class AddTodoForm extends PureComponent { 111 | render() { 112 | const props = this.props; 113 | let textRef; 114 | let tagsRef; 115 | 116 | const onSubmit = () => { 117 | props.onSubmit({ 118 | text: textRef.value, 119 | tags: tagsRef.value, 120 | }); 121 | }; 122 | 123 | return ( 124 |
    125 |
    126 |   127 | textRef = el} 130 | placeholder="Todo Name"/> 131 |   132 |
    133 |
    134 |   135 | tagsRef = el} 138 | placeholder="urgent, personal, work"/> 139 |   140 |
    141 | 142 |
    143 | ); 144 | } 145 | } 146 | 147 | AddTodoForm.propTypes = { 148 | onSubmit: PropTypes.func.isRequired, 149 | }; 150 | 151 | export class UserSelector extends PureComponent { 152 | render() { 153 | const props = this.props; 154 | let selectRef; 155 | 156 | const onChange = () => { 157 | const integerId = parseInt(selectRef.value, 10); 158 | props.onSelect(integerId); 159 | }; 160 | 161 | return ( 162 |
    163 | 164 | 169 |
    170 | ); 171 | } 172 | } 173 | 174 | UserSelector.propTypes = { 175 | onSelect: PropTypes.func.isRequired, 176 | children: PropTypes.arrayOf(PropTypes.element).isRequired, 177 | }; 178 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import createLogger from 'redux-logger'; 6 | import { schema } from './models'; 7 | import { selectedUserIdReducer } from './reducers'; 8 | import bootstrap from './bootstrap'; 9 | import App from './app'; 10 | 11 | const rootReducer = combineReducers({ 12 | orm: schema.reducer(), 13 | selectedUserId: selectedUserIdReducer, 14 | }); 15 | 16 | // We're using `redux-logger`. Open up the console in the demo and you can inspect 17 | // the internal state maintained by Redux-ORM. 18 | const createStoreWithMiddleware = applyMiddleware(createLogger())(createStore); 19 | 20 | const store = createStoreWithMiddleware(rootReducer, bootstrap(schema)); 21 | 22 | function main() { 23 | // In the repo, we have a simple index.html that includes Bootstrap CSS files 24 | // and a div element with id `app`. 25 | const app = document.getElementById('app'); 26 | ReactDOM.render(( 27 | 28 | 29 | 30 | ), app); 31 | } 32 | 33 | main(); 34 | -------------------------------------------------------------------------------- /app/models.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable default-case, no-shadow */ 2 | import { Schema, Model, many, fk } from 'redux-orm'; 3 | import { PropTypes } from 'react'; 4 | import propTypesMixin from 'redux-orm-proptypes'; 5 | import { 6 | CREATE_TODO, 7 | MARK_DONE, 8 | DELETE_TODO, 9 | ADD_TAG_TO_TODO, 10 | REMOVE_TAG_FROM_TODO, 11 | } from './actionTypes'; 12 | 13 | const ValidatingModel = propTypesMixin(Model); 14 | 15 | export class Tag extends ValidatingModel { 16 | static reducer(state, action, Tag) { 17 | const { payload, type } = action; 18 | switch (type) { 19 | case CREATE_TODO: 20 | const tags = payload.tags.split(','); 21 | const trimmed = tags.map(name => name.trim()); 22 | trimmed.forEach(name => Tag.create({ name })); 23 | break; 24 | case ADD_TAG_TO_TODO: 25 | if (!Tag.filter({ name: payload.tag }).exists()) { 26 | Tag.create({ name: payload.tag }); 27 | } 28 | break; 29 | } 30 | } 31 | } 32 | Tag.modelName = 'Tag'; 33 | Tag.backend = { 34 | idAttribute: 'name', 35 | }; 36 | 37 | export class User extends ValidatingModel {} 38 | User.modelName = 'User'; 39 | 40 | export class Todo extends ValidatingModel { 41 | static reducer(state, action, Todo, session) { 42 | const { payload, type } = action; 43 | switch (type) { 44 | case CREATE_TODO: 45 | // Payload includes a comma-delimited string 46 | // of tags, corresponding to the `name` property 47 | // of Tag, which is also its `idAttribute`. 48 | const tagIds = action.payload.tags.split(',').map(str => str.trim()); 49 | 50 | // You can pass an array of ids for many-to-many relations. 51 | // `redux-orm` will create the m2m rows automatically. 52 | const props = Object.assign({}, payload, { tags: tagIds, done: false }); 53 | Todo.create(props); 54 | break; 55 | case MARK_DONE: 56 | // withId returns a Model instance. 57 | // Assignment doesn't mutate Model instances. 58 | Todo.withId(payload).done = true; 59 | break; 60 | case DELETE_TODO: 61 | Todo.withId(payload).delete(); 62 | break; 63 | case ADD_TAG_TO_TODO: 64 | Todo.withId(payload.todo).tags.add(payload.tag); 65 | break; 66 | case REMOVE_TAG_FROM_TODO: 67 | Todo.withId(payload.todo).tags.remove(payload.tag); 68 | break; 69 | } 70 | } 71 | } 72 | Todo.modelName = 'Todo'; 73 | Todo.fields = { 74 | tags: many('Tag', 'todos'), 75 | user: fk('User', 'todos'), 76 | }; 77 | Todo.propTypes = { 78 | text: PropTypes.string.isRequired, 79 | done: PropTypes.bool.isRequired, 80 | user: PropTypes.oneOfType([ 81 | PropTypes.instanceOf(User), 82 | PropTypes.number, 83 | ]).isRequired, 84 | tags: PropTypes.arrayOf(PropTypes.oneOfType([ 85 | PropTypes.instanceOf(Tag), 86 | PropTypes.string, 87 | ])).isRequired, 88 | }; 89 | Todo.defaultProps = { 90 | done: false, 91 | }; 92 | 93 | export const schema = new Schema(); 94 | schema.register(Todo, Tag, User); 95 | 96 | export default schema; 97 | -------------------------------------------------------------------------------- /app/reducers.js: -------------------------------------------------------------------------------- 1 | import { SELECT_USER } from './actionTypes'; 2 | 3 | export function selectedUserIdReducer(state = 0, action) { 4 | const { type, payload } = action; 5 | switch (type) { 6 | case SELECT_USER: 7 | return payload; 8 | default: 9 | return state; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/selectors.js: -------------------------------------------------------------------------------- 1 | import { schema } from './models'; 2 | import { createSelector } from 'reselect'; 3 | 4 | // Selects the state managed by Redux-ORM. 5 | export const ormSelector = state => state.orm; 6 | 7 | // Redux-ORM selectors work with reselect. To feed input 8 | // selectors to a Redux-ORM selector, we use the reselect `createSelector`. 9 | export const todos = createSelector( 10 | // The first input selector should always be the orm selector. 11 | // Behind the scenes, `schema.createSelector` begins a Redux-ORM 12 | // session with the state selected by `ormSelector` and passes 13 | // that Session instance as an argument instead. 14 | // So, `orm` is a Session instance. 15 | ormSelector, 16 | state => state.selectedUserId, 17 | schema.createSelector((orm, userId) => { 18 | console.log('Running todos selector'); 19 | 20 | // We could also do orm.User.withId(userId).todos.map(...) 21 | // but this saves a query on the User table. 22 | // 23 | // `.withRefs` means that the next operation (in this case filter) 24 | // will use direct references from the state instead of Model instances. 25 | // If you don't need any Model instance methods, you should use withRefs. 26 | return orm.Todo.withRefs.filter({ user: userId }).map(todo => { 27 | // `todo.ref` is a direct reference to the state, 28 | // so we need to be careful not to mutate it. 29 | // 30 | // We want to add a denormalized `tags` attribute 31 | // to each of our todos, so we make a shallow copy of `todo.ref`. 32 | const obj = Object.assign({}, todo.ref); 33 | obj.tags = todo.tags.withRefs.map(tag => tag.name); 34 | 35 | return obj; 36 | }); 37 | }) 38 | ); 39 | 40 | export const user = createSelector( 41 | ormSelector, 42 | state => state.selectedUserId, 43 | schema.createSelector((orm, selectedUserId) => { 44 | console.log('Running user selector'); 45 | // .ref returns a reference to the plain 46 | // JavaScript object in the store. 47 | return orm.User.withId(selectedUserId).ref; 48 | }) 49 | ); 50 | 51 | export const users = createSelector( 52 | ormSelector, 53 | schema.createSelector(orm => { 54 | console.log('Running users selector'); 55 | 56 | // `.toRefArray` returns a new Array that includes 57 | // direct references to each User object in the state. 58 | return orm.User.all().toRefArray(); 59 | }) 60 | ); 61 | -------------------------------------------------------------------------------- /app/test/factories.js: -------------------------------------------------------------------------------- 1 | import _factory from 'factory-girl'; 2 | import { Todo, User, Tag } from '../models'; 3 | import bluebird from 'bluebird'; 4 | import { ReduxORMAdapter } from './utils'; 5 | 6 | // factory-girl only works asynchronously with associated models, 7 | // so we need to roll with that even though Redux-ORM is synchronous. 8 | // We promisify factory-girl so we can use Promises instead of callbacks. 9 | const factory = _factory.promisify(bluebird); 10 | 11 | factory.define('Todo', 'Todo', { 12 | id: factory.sequence(n => n), 13 | text: factory.sequence(n => `Todo ${n}`), 14 | user: factory.assoc('User', 'id'), 15 | tags: factory.assocMany('Tag', 'name', 4), // 4 tags for each Todo 16 | done: factory.sequence(n => n % 2 ? true : false), 17 | }); 18 | 19 | factory.define('User', 'User', { 20 | id: factory.sequence(n => n), 21 | name: factory.sequence(n => `User ${n}`), 22 | }); 23 | 24 | factory.define('Tag', 'Tag', { 25 | name: factory.sequence(n => `Tag ${n}`), 26 | }); 27 | 28 | export default factory; 29 | -------------------------------------------------------------------------------- /app/test/testModels.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import { expect } from 'chai'; 3 | import { 4 | CREATE_TODO, 5 | MARK_DONE, 6 | DELETE_TODO, 7 | ADD_TAG_TO_TODO, 8 | REMOVE_TAG_FROM_TODO, 9 | } from '../actionTypes'; 10 | import { schema } from '../models'; 11 | import factory from './factories'; 12 | import Promise from 'bluebird'; 13 | import { applyActionAndGetNextSession, ReduxORMAdapter } from './utils'; 14 | 15 | describe('Models', () => { 16 | // This will be the initial state. 17 | let state; 18 | 19 | // This will be a Session instance with the initial data. 20 | let session; 21 | 22 | beforeEach(done => { 23 | // Get the default state and start a mutating session. 24 | state = schema.getDefaultState(); 25 | session = schema.withMutations(state); 26 | 27 | factory.setAdapter(new ReduxORMAdapter(session)); 28 | 29 | factory.createMany('User', 2).then(users => { 30 | return Promise.all(users.map(user => { 31 | const userId = user.getId(); 32 | 33 | // Create 10 todos for both of our 2 users. 34 | return factory.createMany('Todo', { user: userId }, 10); 35 | })); 36 | }).then(() => { 37 | // Generating data is finished, start up a session. 38 | session = schema.from(state); 39 | 40 | // Let mocha know we're done setting up. 41 | done(); 42 | }); 43 | }); 44 | 45 | it('correctly handle CREATE_TODO', () => { 46 | const todoText = 'New Todo Text!'; 47 | const todoTags = 'testing, nice, cool'; 48 | const user = session.User.first(); 49 | const userId = user.getId(); 50 | 51 | const action = { 52 | type: CREATE_TODO, 53 | payload: { 54 | text: todoText, 55 | tags: todoTags, 56 | user: userId, 57 | }, 58 | }; 59 | 60 | expect(user.todos.count()).to.equal(10); 61 | expect(session.Todo.count()).to.equal(20); 62 | expect(session.Tag.count()).to.equal(80); 63 | 64 | // The below helper function completes an action dispatch 65 | // loop with the given state and action. 66 | // Finally, it returns a new Session started with the 67 | // next state yielded from the dispatch loop. 68 | // With this new Session, we can query the resulting state. 69 | const { 70 | Todo, 71 | Tag, 72 | User, 73 | } = applyActionAndGetNextSession(schema, state, action); 74 | 75 | expect(User.withId(userId).todos.count()).to.equal(11); 76 | expect(Todo.count()).to.equal(21); 77 | expect(Tag.count()).to.equal(83); 78 | 79 | const newTodo = Todo.last(); 80 | 81 | expect(newTodo.text).to.equal(todoText); 82 | expect(newTodo.user.getId()).to.equal(userId); 83 | expect(newTodo.done).to.be.false; 84 | expect(newTodo.tags.map(tag => tag.name)).to.deep.equal(['testing', 'nice', 'cool']); 85 | }); 86 | 87 | it('correctly handle MARK_DONE', () => { 88 | const todo = session.Todo.filter({ done: false }).first(); 89 | const todoId = todo.getId(); 90 | 91 | const action = { 92 | type: MARK_DONE, 93 | payload: todoId, 94 | }; 95 | 96 | expect(todo.done).to.be.false; 97 | 98 | const { Todo } = applyActionAndGetNextSession(schema, state, action); 99 | 100 | expect(Todo.withId(todoId).done).to.be.true; 101 | }); 102 | 103 | it('correctly handle DELETE_TODO', () => { 104 | const todo = session.Todo.first(); 105 | const todoId = todo.getId(); 106 | 107 | const action = { 108 | type: DELETE_TODO, 109 | payload: todoId, 110 | }; 111 | 112 | expect(session.Todo.count()).to.equal(20); 113 | 114 | const { Todo } = applyActionAndGetNextSession(schema, state, action); 115 | 116 | expect(Todo.count()).to.equal(19); 117 | expect(() => Todo.withId(todoId)).to.throw(Error); 118 | }); 119 | 120 | it('correctly handle ADD_TAG_TO_TODO', () => { 121 | const todo = session.Todo.first(); 122 | const todoId = todo.getId(); 123 | 124 | const newTagName = 'coolnewtag'; 125 | 126 | const action = { 127 | type: ADD_TAG_TO_TODO, 128 | payload: { 129 | todo: todoId, 130 | tag: newTagName, 131 | }, 132 | }; 133 | 134 | expect(session.Tag.count()).to.equal(80); 135 | 136 | const { Todo, Tag } = applyActionAndGetNextSession(schema, state, action); 137 | 138 | expect(Tag.count()).to.equal(81); 139 | expect(Tag.last().name).to.equal(newTagName); 140 | 141 | expect(Todo.withId(todoId).tags.withRefs.map(tag => tag.name)).to.include(newTagName); 142 | }); 143 | 144 | it('correctly handles REMOVE_TAG_FROM_TODO', () => { 145 | const todo = session.Todo.first(); 146 | const todoId = todo.getId(); 147 | 148 | const removeTagId = todo.tags.first().getId(); 149 | 150 | const action = { 151 | type: REMOVE_TAG_FROM_TODO, 152 | payload: { 153 | todo: todoId, 154 | tag: removeTagId, 155 | }, 156 | }; 157 | 158 | expect(session.Tag.count()).to.equal(80); 159 | 160 | const { Todo, Tag } = applyActionAndGetNextSession(schema, state, action); 161 | 162 | // Tag count should remain the same. 163 | expect(Tag.count()).to.equal(80); 164 | 165 | expect(Todo.withId(todoId).tags.withRefs.map(tag => tag.name)).to.not.include(removeTagId); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /app/test/testSelectors.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0, no-shadow: 0 */ 2 | import { expect } from 'chai'; 3 | import { 4 | users, 5 | user, 6 | todos, 7 | } from '../selectors'; 8 | import { schema } from '../models'; 9 | import factory from './factories'; 10 | import Promise from 'bluebird'; 11 | import { applyActionAndGetNextSession, ReduxORMAdapter } from './utils'; 12 | 13 | describe('Selectors', () => { 14 | let ormState; 15 | let session; 16 | let state; 17 | 18 | beforeEach(done => { 19 | ormState = schema.getDefaultState(); 20 | 21 | session = schema.withMutations(ormState); 22 | 23 | factory.setAdapter(new ReduxORMAdapter(session)); 24 | 25 | factory.createMany('User', 2).then(users => { 26 | return Promise.all(users.map(user => { 27 | const userId = user.getId(); 28 | 29 | return factory.createMany('Todo', { user: userId }, 10); 30 | })); 31 | }).then(() => { 32 | session = schema.from(ormState); 33 | 34 | state = { 35 | orm: ormState, 36 | selectedUserId: session.User.first().getId(), 37 | }; 38 | 39 | done(); 40 | }); 41 | }); 42 | 43 | it('users works', () => { 44 | const result = users(state); 45 | 46 | expect(result).to.have.length(2); 47 | expect(result[0]).to.contain.all.keys(['id', 'name']); 48 | }); 49 | 50 | it('todos works', () => { 51 | const result = todos(state); 52 | 53 | expect(result).to.have.length(10); 54 | expect(result[0]).to.contain.all.keys(['id', 'text', 'user', 'tags', 'done']); 55 | expect(result[0].tags).to.have.length(4); 56 | }); 57 | 58 | it('user works', () => { 59 | const result = user(state); 60 | expect(result).to.be.an('object'); 61 | expect(result).to.contain.all.keys(['id', 'name']); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /app/test/utils.js: -------------------------------------------------------------------------------- 1 | export function applyActionAndGetNextSession(schema, state, action) { 2 | const nextState = schema.from(state, action).reduce(); 3 | return schema.from(nextState); 4 | } 5 | 6 | export class ReduxORMAdapter { 7 | constructor(session) { 8 | this.session = session; 9 | } 10 | 11 | build(modelName, props) { 12 | return this.session[modelName].create(props); 13 | } 14 | 15 | get(doc, attr) { 16 | return doc[attr]; 17 | } 18 | 19 | set(props, doc) { 20 | doc.update(props); 21 | } 22 | 23 | save(doc, modelName, cb) { 24 | process.nextTick(cb); 25 | } 26 | 27 | destroy(doc, modelName, cb) { 28 | doc.delete(); 29 | process.nextTick(cb); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const ghPages = require('gulp-gh-pages'); 3 | 4 | gulp.task('deploy', () => { 5 | return gulp.src('./build/**/*') 6 | .pipe(ghPages()); 7 | }); 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= htmlWebpackPlugin.options.title %> 6 | 7 | 8 | 9 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-orm-primer", 3 | "version": "0.1.0", 4 | "description": "A guide to creating a Todo application with Redux-ORM", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack-dev-server --hot", 9 | "build": "webpack" 10 | }, 11 | "keywords": [ 12 | "redux", 13 | "orm", 14 | "redux-orm" 15 | ], 16 | "author": "Tommi Kaikkonen", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "babel-cli": "^6.10.1", 20 | "babel-core": "^6.9.1", 21 | "babel-eslint": "^6.1.0", 22 | "babel-loader": "^6.2.4", 23 | "babel-preset-es2015": "^6.9.0", 24 | "babel-preset-react": "^6.5.0", 25 | "babel-preset-stage-2": "^6.5.0", 26 | "bluebird": "^3.1.2", 27 | "chai": "^3.4.1", 28 | "clean-webpack-plugin": "^0.1.5", 29 | "factory-girl": "^2.2.2", 30 | "gulp": "^3.9.0", 31 | "gulp-gh-pages": "^0.5.4", 32 | "html-webpack-plugin": "^2.21.0", 33 | "lodash": "^3.10.1", 34 | "mocha": "^2.3.4", 35 | "react-pure-render": "^1.0.2", 36 | "react-redux": "^4.0.0", 37 | "redux": "^3.0.4", 38 | "redux-diff-logger": "0.0.6", 39 | "redux-logger": "^2.4.0", 40 | "redux-orm": "^0.8.1", 41 | "redux-orm-proptypes": "^0.1.0", 42 | "reselect": "^2.0.1", 43 | "webpack": "^1.13.1", 44 | "webpack-dev-server": "^1.14.1", 45 | "webpack-merge": "^0.14.0" 46 | }, 47 | "dependencies": { 48 | "react": "^15.1.0", 49 | "react-dom": "^15.1.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tommikaikkonen/redux-orm-primer/dc0a17d93bbfe4b5bc31a2042a73c6ca1a83190a/screenshot.png -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlwebpackPlugin = require('html-webpack-plugin'); 3 | const webpack = require('webpack'); 4 | const merge = require('webpack-merge'); 5 | const Clean = require('clean-webpack-plugin'); 6 | 7 | const TARGET = process.env.npm_lifecycle_event; 8 | 9 | const PATHS = { 10 | app: path.join(__dirname, 'app'), 11 | build: path.join(__dirname, 'build'), 12 | }; 13 | 14 | const common = { 15 | entry: PATHS.app, 16 | resolve: { 17 | extensions: ['', '.js', '.jsx'], 18 | fallback: path.join(__dirname, 'node_modules'), 19 | }, 20 | module: { 21 | loaders: [ 22 | { 23 | test: /\.jsx?$/, 24 | loaders: ['babel'], 25 | include: PATHS.app, 26 | }, 27 | ], 28 | }, 29 | plugins: [ 30 | new HtmlwebpackPlugin({ 31 | title: 'Redux-ORM Primer', 32 | inject: 'body', 33 | template: 'index.html', 34 | }), 35 | ], 36 | }; 37 | 38 | if (TARGET === 'start' || !TARGET) { 39 | module.exports = merge(common, { 40 | plugins: [ 41 | new webpack.HotModuleReplacementPlugin(), 42 | ], 43 | devServer: { 44 | historyApiFallback: true, 45 | inline: true, 46 | progress: true, 47 | 48 | // display only errors to reduce the amount of output 49 | stats: 'errors-only', 50 | 51 | // parse host and port from env so this is easy 52 | // to customize 53 | host: process.env.HOST, 54 | port: process.env.PORT, 55 | }, 56 | }); 57 | } 58 | 59 | if (TARGET === 'build') { 60 | module.exports = merge(common, { 61 | output: { 62 | path: PATHS.build, 63 | filename: 'bundle.js', 64 | }, 65 | devtool: 'source-map', 66 | plugins: [ 67 | new Clean([PATHS.build]), 68 | new webpack.optimize.UglifyJsPlugin({ 69 | compress: { 70 | warnings: false, 71 | }, 72 | }), 73 | new webpack.DefinePlugin({ 74 | 'process.env.NODE_ENV': JSON.stringify('production'), 75 | }), 76 | ], 77 | }); 78 | } 79 | --------------------------------------------------------------------------------