├── .gitignore
├── .eslintignore
├── specs
├── wireframe.png
└── SPECS.md
├── .travis.yml
├── assets
├── images
│ ├── favicon.ico
│ └── search.svg
└── clients.json
├── .stylelintrc
├── .eslintrc.js
├── .babelrc
├── src
├── styles
│ ├── utils.css
│ ├── initial.css
│ ├── variables.css
│ └── reset.css
├── components
│ ├── ListContainer.css
│ ├── List.css
│ ├── App.css
│ ├── ListSearch.css
│ ├── App.js
│ ├── ListSearch.js
│ ├── DetailContainer.js
│ ├── SearchContainer.js
│ ├── Search.css
│ ├── List.js
│ ├── utils.js
│ ├── ListItem.js
│ ├── ListItem.css
│ ├── Search.js
│ ├── Detail.css
│ ├── Detail.js
│ └── ListContainer.js
├── utils
│ ├── constants.js
│ ├── index.js
│ └── asyncFetchDataRx.js
├── index.html
├── index.css
├── actions
│ └── index.js
├── index.js
└── reducers
│ └── index.js
├── test
├── test-reducers-fetchClients.js
├── test-asyncFetchDataRx.js
├── test-reducers-setFilter.js
├── test-reducers-selectClient.js
├── test-reducers-receivedClientsData.js
├── test-utils.js
└── test-reducers-clientsDataLoading.js
├── server
├── server.js
└── webpack-dev-server.js
├── README.md
├── docs
├── createState.md
├── README.md
├── connectWith.md
├── appIndex.md
├── asyncFetch.md
├── actions.md
└── reducer.md
├── webpack
├── webpack.prod.config.js
├── webpack.base.config.js
└── webpack.dev.config.js
├── LICENSE
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .DS_Store
4 | public
5 | archive
6 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | webpack.config.js
3 | webpack/*
4 | server/*
5 | archive/*
6 |
--------------------------------------------------------------------------------
/specs/wireframe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacz/rxr-redux-example/HEAD/specs/wireframe.png
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "5"
4 | script:
5 | - npm run lint
6 | - npm test
7 |
--------------------------------------------------------------------------------
/assets/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dacz/rxr-redux-example/HEAD/assets/images/favicon.ico
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "comment-whitespace-inside": "never"
4 | }
5 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: "dacz",
3 | rules: {
4 | 'no-param-reassign': 0
5 | }
6 | };
7 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015", "stage-0"],
3 | "plugins": [
4 | "transform-runtime"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/src/styles/utils.css:
--------------------------------------------------------------------------------
1 |
2 | .fix {
3 | &::after {
4 | content: "";
5 | display: table;
6 | clear: both;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/ListContainer.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .message {
4 | text-align: center;
5 | padding: var(--pad);
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/constants.js:
--------------------------------------------------------------------------------
1 | export const IS_LOADING = 'IS_LOADING';
2 | export const OUTDATED_LOADING = 1000 * 30; // 30 sec to load
3 | export const CLIENTS_DATA_URL = '/clients.json';
4 |
--------------------------------------------------------------------------------
/src/components/List.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | padding: 0;
6 | height: 100%;
7 | overflow-y: scroll;
8 | overflow-x: hidden;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/App.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | padding: 0;
6 | height: 100%;
7 | display: flex;
8 | flex-wrap: nowrap;
9 | align-items: stretch;
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export const findInObj = (obj, val) => {
2 | const re = new RegExp(val, 'i');
3 | return !!Object.keys(obj).filter(item => {
4 | if (typeof obj[item] === 'object') return findInObj(obj[item], val);
5 | return obj[item].match(re);
6 | }).length;
7 | };
8 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | My App
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/components/ListSearch.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | padding: 0;
6 | width: var(--listSearchWidth);
7 | display: flex;
8 | flex-direction: column;
9 | flex-wrap: nowrap;
10 | height: 100%;
11 | border-right: 1px solid var(--lineColor);
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ListSearch from './ListSearch';
4 | import DetailContainer from './DetailContainer';
5 | import styles from './App.css';
6 |
7 | const App = () => (
8 |
9 |
10 |
11 |
12 | );
13 |
14 | export default App;
15 |
--------------------------------------------------------------------------------
/src/components/ListSearch.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SearchContainer from './SearchContainer';
3 | import ListContainer from './ListContainer';
4 |
5 | import styles from './ListSearch.css';
6 |
7 | const ListSearch = () => (
8 |
9 |
10 |
11 |
12 | );
13 |
14 | export default ListSearch;
15 |
--------------------------------------------------------------------------------
/src/components/DetailContainer.js:
--------------------------------------------------------------------------------
1 | import { connectWithState } from 'rxr-react';
2 | import Detail from './Detail';
3 |
4 | const findClient = (id, clientsData) => clientsData.filter(client => (client._id === id))[0];
5 |
6 | const selector = (state) => ({
7 | clientData: findClient(state.selectedClient, state.clients.data),
8 | });
9 |
10 | const DetailContainer = connectWithState(selector)(Detail);
11 |
12 | export default DetailContainer;
13 |
--------------------------------------------------------------------------------
/test/test-reducers-fetchClients.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import actions from '../src/actions';
3 | import reducer$ from '../src/reducers';
4 |
5 |
6 | // ---- fetchClients$
7 |
8 | // test is passing but not testing what I want
9 | test('fetchClients$', t => {
10 | reducer$.subscribe(() => {
11 | // console.log('TEST SUBSCRIBE VAL: ', val);
12 | t.pass();
13 | });
14 | actions.fetchClients$.next('http://jsonplaceholder.typicode.com/users');
15 | });
16 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | var express = require("express");
2 | var path = require("path");
3 | var config = require("../webpack/webpack.base.config.js");
4 |
5 | var server = express();
6 |
7 | server.use(express.static(config.publicBasePath));
8 |
9 | server.get('*', function (req, res) {
10 | res.sendFile(path.resolve(config.publicBasePath + "/index.html"));
11 | });
12 |
13 | var listener = server.listen(process.env.PORT || 8080, function () {
14 | console.log("Express server listening on port %d", listener.address().port)
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/SearchContainer.js:
--------------------------------------------------------------------------------
1 | import { connectWithState } from 'rxr-react';
2 | import actionStreams from '../actions';
3 | import Search from './Search';
4 |
5 | // the selector is very similar to Redux.
6 | // We can use it to pass the messageStream
7 | // functions too, because we do not need dispatch.
8 | const selector = (state) => ({
9 | filter: state.filter,
10 | setFilter: actionStreams.setFilter,
11 | });
12 |
13 | const SearchContainer = connectWithState(selector)(Search);
14 |
15 | export default SearchContainer;
16 |
--------------------------------------------------------------------------------
/src/styles/initial.css:
--------------------------------------------------------------------------------
1 | html { box-sizing: border-box; }
2 |
3 | *,
4 | *::before,
5 | *::after {
6 | box-sizing: inherit;
7 | }
8 |
9 | html { border-collapse: collapse; }
10 | * { border-collapse: inherit; }
11 |
12 | html,
13 | body {
14 | margin: 0;
15 | padding: 0;
16 | background-color: #eee;
17 | height: 100%;
18 | }
19 |
20 | *:not(body) {
21 | background-repeat: no-repeat;
22 | background-position: 50%;
23 | background-size: cover;
24 | }
25 |
26 | /* img {
27 | max-width: 100%;
28 | height: auto;
29 | border: 0;
30 | } */
31 |
--------------------------------------------------------------------------------
/server/webpack-dev-server.js:
--------------------------------------------------------------------------------
1 | var webpack = require("webpack");
2 | var WebpackDevServer = require("webpack-dev-server");
3 | var config = require("../webpack/webpack.dev.config");
4 | console.log(config);
5 |
6 | var webpackDevServer = new WebpackDevServer(webpack(config), {
7 | // hot: true,
8 | inline: true,
9 | historyApiFallback: true,
10 | stats: { colors: true },
11 | });
12 |
13 | webpackDevServer.listen(8080, "localhost", function (err) {
14 | if (err) throw err;
15 | console.log("Webpack Dev Server started at %d", 8080);
16 | });
17 |
--------------------------------------------------------------------------------
/src/components/Search.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | padding: calc(var(--pad) / 1.5 - 3px) var(--pad) calc(var(--pad) / 1.5);
6 | background-color: var(--bgrSearch);
7 | }
8 |
9 | .label {
10 | display: none;
11 | }
12 |
13 | .input {
14 | border: 1px solid var(--lineColor);
15 | padding: 2px 5px;
16 | padding-right: 28px;
17 | width: 100%;
18 | background-image: url('/images/search.svg');
19 | background-size: 20px 20px;
20 | background-position: 98%;
21 | &:focus {
22 | outline: none;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import './styles/variables.css';
2 | @import "./styles/reset.css";
3 | @import "./styles/initial.css";
4 | @import "./styles/utils.css";
5 |
6 | html,
7 | body {
8 | font-family: var(--mainFont);
9 | height: 100%;
10 | }
11 |
12 | html,
13 | body,
14 | div,
15 | span,
16 | p,
17 | input,
18 | textarea {
19 | font-family: var(--mainFont);
20 | font-size: var(--baseFontSize);
21 | line-height: var(--baseLineHeight);
22 | }
23 |
24 | :global #index {
25 | height: 100%;
26 | }
27 |
28 | :global #app {
29 | position: relative;
30 | height: 100%;
31 | }
32 |
--------------------------------------------------------------------------------
/test/test-asyncFetchDataRx.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import asyncFetchDataRx from '../src/utils/asyncFetchDataRx';
3 | const CLIENTS_DATA_URL = 'http://jsonplaceholder.typicode.com/users';
4 |
5 | test('asyncFetchDataRx', t => {
6 | const ds$ = asyncFetchDataRx(CLIENTS_DATA_URL);
7 | return ds$.do((data) => {
8 | t.is(data.length, 10);
9 | });
10 | });
11 |
12 | test('asyncFetchDataRx - error', t => {
13 | const ds$ = asyncFetchDataRx('http://somenonexistenturlshjshdj.com');
14 | return ds$.do((err) => {
15 | t.true(err instanceof Error);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/test/test-reducers-setFilter.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import actions from '../src/actions';
3 | import reducer$ from '../src/reducers';
4 |
5 |
6 | test('setFilter$', t => {
7 | const initialState = {};
8 | const valWanted = [
9 | { filter: 'somefilter' },
10 | { filter: 'elsefilter' },
11 | { filter: '' },
12 | ];
13 | reducer$.subscribe(fn => {
14 | t.deepEqual(fn(initialState), valWanted.shift());
15 | });
16 |
17 | actions.setFilter$.next('somefilter');
18 | actions.setFilter$.next('elsefilter');
19 | actions.setFilter$.next();
20 | });
21 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import { createMessageStreams, messageStreamsMonitor$ } from 'rxr';
2 |
3 | // you should make some optimization to exclude messageStreamsMonitor$
4 | // from production build, like: (if you use webpack definePlugin)
5 | const monitor$ = process.env.NODE_ENV === 'production' ? undefined : messageStreamsMonitor$;
6 |
7 | const actionStreams = createMessageStreams([
8 | 'clientsDataLoading',
9 | 'setFilter',
10 | 'selectClient',
11 | 'receivedClientsData',
12 | 'fetchClients'
13 | ], { messageStreamsMonitor$: monitor$ });
14 |
15 | export default actionStreams;
16 |
--------------------------------------------------------------------------------
/src/utils/asyncFetchDataRx.js:
--------------------------------------------------------------------------------
1 | import Rx from 'rxjs';
2 | import 'rxjs/add/operator/catch';
3 | import fetch from 'isomorphic-fetch';
4 | import {
5 | CLIENTS_DATA_URL,
6 | } from './constants';
7 |
8 | /**
9 | * Async data loading
10 | *
11 | * @param {[string]} url to load from
12 | * @return {[stream]} Observable stream of data
13 | */
14 | const asyncFetchDataRx = (url = CLIENTS_DATA_URL) => (
15 | Rx.Observable.fromPromise(fetch(url))
16 | .flatMap(response => Rx.Observable.fromPromise(response.json()))
17 | .catch(err => Rx.Observable.of(new Error(err)))
18 | );
19 |
20 | export default asyncFetchDataRx;
21 |
--------------------------------------------------------------------------------
/test/test-reducers-selectClient.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import actions from '../src/actions';
3 | import reducer$ from '../src/reducers';
4 |
5 |
6 | test('selectClient$', t => {
7 | const initialState = {};
8 | const valWanted = [
9 | { selectedClient: 'someid' },
10 | { selectedClient: '' },
11 | { selectedClient: '' },
12 | { selectedClient: 'elseId' },
13 | ];
14 | reducer$.subscribe(fn => {
15 | t.deepEqual(fn(initialState), valWanted.shift());
16 | });
17 |
18 | actions.selectClient$.next('someid');
19 | actions.selectClient$.next('');
20 | actions.selectClient$.next();
21 | actions.selectClient$.next('elseId');
22 | });
23 |
--------------------------------------------------------------------------------
/test/test-reducers-receivedClientsData.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import actions from '../src/actions';
3 | import reducer$ from '../src/reducers';
4 | import clients from '../assets/clients.json';
5 |
6 |
7 | test('receivedClientsData$', t => {
8 | const initialState = {};
9 | const ts = Date.now();
10 |
11 | reducer$.subscribe(fn => {
12 | const clientsState = fn(initialState).clients;
13 | t.is(clientsState.data.length, clients.length);
14 | t.is(clientsState.ts, ts);
15 | t.is(clientsState.status, undefined);
16 | t.is(clientsState.error, undefined);
17 | });
18 |
19 | const error = undefined;
20 | actions.receivedClientsData$.next({ data: clients, error, ts });
21 | });
22 |
--------------------------------------------------------------------------------
/src/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --mainFont: Helvetica, Arial;
3 | --baseFontSize: 16px;
4 | --baseLineHeight: 1.45;
5 | --smallerFontSize: 0.75em;
6 |
7 | --pad: 16px;
8 |
9 | --mainColor: #0076bd;
10 | --textColor: #333;
11 | --dimmedTextColor: #888;
12 | --bgrApp: white;
13 | --bgrListPane: #eee;
14 | --bgrDetail: white;
15 | --bgrSearch: #82c9f4;
16 | --lineColor: #ddd;
17 |
18 | --avatarSize: 128px;
19 | --avatarListSize: 46px;
20 |
21 | --listSearchWidth: 400px;
22 | }
23 |
24 | /* not used now */
25 | @custom-media --small-screen only screen and (width <= 640px);
26 | @custom-media --medium-screen only screen and (width > 641px) and (width <= 1024px);
27 | @custom-media --large-screen only screen and (width > 1024px);
28 |
--------------------------------------------------------------------------------
/src/components/List.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import ListItem from './ListItem';
3 |
4 | import styles from './List.css';
5 |
6 | const List = ({ clients = [], selectedClient, actions }) => {
7 | const items = clients
8 | .map(item => (
9 | )
15 | );
16 |
17 | return (
18 |
19 | { items }
20 |
21 | );
22 | };
23 |
24 | List.propTypes = {
25 | clients: PropTypes.array,
26 | selectedClient: PropTypes.string,
27 | actions: PropTypes.object,
28 | };
29 |
30 | export default List;
31 |
--------------------------------------------------------------------------------
/test/test-utils.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import deepFreeze from 'deep-freeze';
3 | import { findInObj } from '../src/utils';
4 |
5 | test('findInObj - plain object', t => {
6 | const obj = {
7 | prop1: 'val1',
8 | prop2: 'aval2i',
9 | };
10 | deepFreeze(obj);
11 | t.true(findInObj(obj, 'val1'));
12 | t.true(findInObj(obj, 'VAL2'));
13 | t.false(findInObj(obj, 'nothere'));
14 | });
15 |
16 | test('findInObj - nested object', t => {
17 | const obj = {
18 | prop1: 'val1',
19 | prop2: 'aval2i',
20 | prop3: {
21 | nprop1: 'nestedv1',
22 | nprop2: 'nestedv2',
23 | },
24 | };
25 | deepFreeze(obj);
26 | t.true(findInObj(obj, 'tedv2'));
27 | t.true(findInObj(obj, 'TEDV1'));
28 | t.false(findInObj(obj, 'nothere'));
29 | });
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rxr-redux-example
2 |
3 | [](https://travis-ci.org/dacz/rxr-redux-example)
4 |
5 | RxR and Redux side by side to learn the principles.
6 |
7 | **[Read the docs about differences between Redux and RxR app ... »](./docs/README.md)**
8 |
9 | ### Example app (this repo)
10 |
11 | Read the source for RxR example app, download, play with it, enhance.
12 |
13 | ```
14 | npm install
15 | npm run dev
16 | ```
17 |
18 | [Specs](./specs/SPECS.md) of this example.
19 |
20 | Leave feedback in issues - about anything.
21 |
22 | Any enhancements of the code - RxR or Redux (once it will be there) are welcome! We are learning together.
23 |
24 | ### Then refactor your current Redux app to RxR :)
25 |
26 | ### Smile!
27 |
--------------------------------------------------------------------------------
/src/components/utils.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 |
3 | export const fullname = (data) => `${data.general.firstName} ${data.general.lastName}`;
4 |
5 | export const AvatarImg = ({ clientData }) => (
6 |
7 | );
8 |
9 | AvatarImg.propTypes = {
10 | clientData: PropTypes.object.isRequired,
11 | };
12 |
13 | // this is not generic, but for our purpose it is easier (we need name, email, etc.)
14 | export const EmailLink = ({ clientData, ...other }) => (
15 |
20 | { clientData.contact.email }
21 |
22 | );
23 |
24 | EmailLink.propTypes = {
25 | clientData: PropTypes.object.isRequired,
26 | };
27 |
--------------------------------------------------------------------------------
/docs/createState.md:
--------------------------------------------------------------------------------
1 | # Create state
2 |
3 | Redux creates store not state but store in the main file `index.js`. We [talked about it](./appIndex.md) when we were discussing this file. It is the object that stores and manages state with `dispatch`, have `getState()` function to obtain state and have some other functions.
4 |
5 | RxR creates state stream as we briefly mentioned. This stream is again usual `Rx.Observable`. It doesn't need any `getState`, any `dispatch`. In the [RxR gitbook](https://dacz.github.io/rxr/docs/basics/StoreState.html) you can see how the state stream is created. It's beautiful in my opinion :)
6 |
7 | I wanted to mention it because it's better for understanding similarities with `connect` (Redux) and `connectWithState` (RxR).
8 |
9 | ---
10 |
11 | So let's [connect with state! ... »](./connectWith.md)
12 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill'; // eslint-disable-line
2 | import React from 'react';
3 | import { render } from 'react-dom';
4 | import { Provider } from 'rxr-react';
5 | import { createState, createLoggerStream, startLogging, messageStreamsMonitor$ } from 'rxr';
6 |
7 | import styles from './index.css'; // eslint-disable-line
8 |
9 | import App from './components/App';
10 |
11 | import reducer$ from './reducers';
12 |
13 | const initialState = {
14 | clients: { data: [], ts: 0, status: undefined },
15 | filter: '',
16 | selectedClient: '',
17 | };
18 |
19 | const state$ = createState(reducer$, initialState);
20 |
21 | const loggerStream$ = createLoggerStream(state$, messageStreamsMonitor$);
22 | startLogging(loggerStream$);
23 |
24 | render(
25 |
26 |
27 | , document.getElementById('index')
28 | );
29 |
--------------------------------------------------------------------------------
/specs/SPECS.md:
--------------------------------------------------------------------------------
1 | # What we want to build
2 |
3 | ## Specification
4 |
5 | Create one-page application with list of clients and their details. Clients data are stored in 'clients.json' which will be loaded dynamically via http request.
6 |
7 | ## Wireframe description:
8 | * **Clients list:** Every item will display smaller avatar clients name and job title. When user clicks on an item, it will display client's details on the right.
9 | * **Search:** It will search all informations, not only those showed in the clients list. Results are displayed as filtered out list of clients and they will appear instantly as user types.
10 | * **Client's details:** Full size avatar (128x128px) and all other client's informations.
11 |
12 | Next ... [let's see the app main file (index.js) ... »](../docs/appIndex.md)
13 |
14 | 
15 |
--------------------------------------------------------------------------------
/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | button {
2 | border: none;
3 | margin: 0;
4 | padding: 0;
5 | width: auto;
6 | overflow: visible;
7 |
8 | background: transparent;
9 |
10 | /* inherit font & color from ancestor */
11 | color: inherit;
12 | font: inherit;
13 |
14 | /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
15 | line-height: normal;
16 |
17 | /* Corrects font smoothing for webkit */
18 | -webkit-font-smoothing: inherit;
19 | -moz-osx-font-smoothing: inherit;
20 |
21 | /* Corrects inability to style clickable `input` types in iOS */
22 | -webkit-appearance: none;
23 |
24 | &::-moz-focus-inner {
25 | border: 0;
26 | padding: 0;
27 | }
28 | }
29 |
30 | input,
31 | [type="input"] {
32 | border: none;
33 | font-family: inherit;
34 |
35 | &::-moz-focus-inner {
36 | padding: 0;
37 | border: 0;
38 | }
39 | }
40 |
41 | ul {
42 | list-style: none;
43 |
44 | margin: 0;
45 | padding: 0;
46 | }
47 |
48 | tr,
49 | td {
50 | vertical-align: top;
51 | margin: 0;
52 | padding: 0;
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/ListItem.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import {
3 | fullname,
4 | AvatarImg,
5 | } from './utils';
6 | import styles from './ListItem.css';
7 |
8 | const ListItem = ({ data, selectedClient, selectClient }) => {
9 | const handleSelectClient = () => selectClient(data._id);
10 | const style = selectedClient === data._id ? styles.containerSelected : styles.container;
11 |
12 | return (
13 |
14 |
17 |
18 |
{ fullname(data) }
19 |
20 | { data.job.title }
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | ListItem.propTypes = {
28 | data: PropTypes.object.isRequired,
29 | selectedClient: PropTypes.string,
30 | selectClient: PropTypes.func.isRequired,
31 | };
32 |
33 | export default ListItem;
34 |
--------------------------------------------------------------------------------
/webpack/webpack.prod.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require("webpack");
2 | var config = require("./webpack.base.config.js");
3 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
4 |
5 | var prodConfig = {
6 | devtool: "cheap-module-source-map",
7 |
8 | entry: config.entry,
9 |
10 | resolve: config.resolve,
11 |
12 | output: config.output,
13 |
14 | plugins: config.plugins.concat([
15 | // new ExtractTextPlugin("styles.css"),
16 | new webpack.optimize.DedupePlugin(),
17 | new webpack.optimize.OccurrenceOrderPlugin(),
18 | new webpack.DefinePlugin({
19 | 'process.env': {
20 | 'NODE_ENV': JSON.stringify('production'),
21 | }
22 | }),
23 | new webpack.optimize.UglifyJsPlugin()
24 | ]),
25 |
26 | module: {
27 | loaders: config.module.loaders,
28 | },
29 |
30 | postcss: function (webpack) {
31 | return config.postcssPlugins;
32 | }
33 |
34 | };
35 |
36 | console.log('============');
37 | console.log(JSON.stringify(prodConfig, null, 2));
38 | console.log('============');
39 |
40 | module.exports = prodConfig;
41 |
--------------------------------------------------------------------------------
/src/components/ListItem.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | padding: calc(var(--pad) / 2) var(--pad);
6 | white-space: nowrap;
7 | border-bottom: 1px solid color(var(--lineColor) blackness(+5%));
8 | display: flex;
9 | flex-wrap: nowrap;
10 | align-items: center;
11 | background-color: #eee;
12 | cursor: pointer;
13 | }
14 |
15 | .containerSelected {
16 | composes: container;
17 | box-shadow: 0 0 3px 0 rgba(0, 0, 0, 0.1);
18 | transform: scale(1.05);
19 | background-color: color(var(--lineColor) blackness(+5%));
20 | }
21 |
22 | .avatar {
23 | height: var(--avatarListSize);
24 | width: var(--avatarListSize);
25 | img {
26 | border-radius: calc(var(--avatarListSize) / 2);
27 | width: 100%;
28 | height: 100%;
29 | }
30 | }
31 |
32 | .data {
33 | margin-left: var(--pad);
34 | }
35 |
36 | .fullName {
37 | font-size: 0.9em;
38 | font-weight: bold;
39 | }
40 |
41 | .jobTitle {
42 | font-size: var(--smallerFontSize);
43 | color: var(--dimmedTextColor);
44 | overflow-x: hidden;
45 | white-space: nowrap;
46 | text-overflow: ellipsis;
47 | }
48 |
--------------------------------------------------------------------------------
/test/test-reducers-clientsDataLoading.js:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import actions from '../src/actions';
3 | import reducer$ from '../src/reducers';
4 | import {
5 | IS_LOADING,
6 | } from '../src/utils/constants';
7 |
8 |
9 | test('clientsDataLoading$', t => {
10 | const initialState = {};
11 | const ts = Date.now();
12 | const valWanted = [
13 | { clients: { status: IS_LOADING, ts } },
14 | ];
15 | reducer$.subscribe(fn => {
16 | t.deepEqual(fn(initialState), valWanted.shift());
17 | });
18 |
19 | actions.clientsDataLoading$.next(ts);
20 | });
21 |
22 | test('clientsDataLoading$ previous data', t => {
23 | const ts = Date.now();
24 | const initialState = {
25 | clients: { status: undefined, ts: ts - 10000, data: [] },
26 | filter: 'somefilter',
27 | };
28 | const valWanted = [
29 | {
30 | clients: { status: IS_LOADING, ts, data: initialState.clients.data },
31 | filter: initialState.filter,
32 | },
33 | ];
34 | reducer$.subscribe(fn => {
35 | t.deepEqual(fn(initialState), valWanted.shift());
36 | });
37 |
38 | actions.clientsDataLoading$.next(ts);
39 | });
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 David Cizek
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Let's go
2 |
3 | I want to show similarities with Redux app (and how easy it can be rewritten with RxR - RxJS). I do not want to diminish value of Redux at all - I like Redux and I like RxJS. It inspires me and it's up to the project and personal preference to choose the right direction and tool for every project. With RxR you are very very close to Redux so you are not trapped in some different approach.
4 |
5 | RxR is here to encourage you to learn (and try to use) [RxJS](https://github.com/ReactiveX/rxjs). RxJS is great. Really.
6 |
7 | Let's go through the app.
8 |
9 | ### Content
10 |
11 | * [specs](../specs/SPECS.md) (what is this example about)
12 | * [main script (index.js)](./appIndex.md)
13 | * [actions and message streams](./actions.md)
14 | * [reducer](./reducer.md)
15 | * [create store or state?](./createState.md)
16 | * [connect with state](./connectWith.md)
17 | * [async fetch and RxJS](./asyncFetch.md)
18 |
19 | First - [read the specs](../specs/SPECS.md). You should know what we are building... [»](../specs/SPECS.md)
20 |
21 | [**RxR gitbook**](https://dacz.github.io/rxr/)
22 |
23 |
24 | ### Updates
25 |
26 | * 2016-07-24: updated RxR version with logging
27 |
--------------------------------------------------------------------------------
/assets/images/search.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/components/Search.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import styles from './Search.css';
3 |
4 | class Search extends Component {
5 |
6 | static propTypes = {
7 | setFilter: PropTypes.func.isRequired,
8 | filter: PropTypes.string
9 | }
10 |
11 | componentDidMount() {
12 | this._input.focus();
13 | this._input.value = this.props.filter;
14 | this.searchString = this.props.filter;
15 | }
16 |
17 | searchString = '';
18 | _input;
19 |
20 | handleKeyUpSearch = (e) => {
21 | e.preventDefault();
22 | const val = e.target.value.trim();
23 | if (val === this.searchString) return false;
24 | this.searchString = val;
25 | this.props.setFilter(val);
26 | return false;
27 | }
28 |
29 | render() {
30 | return (
31 |
32 |
33 | this._input = c} // eslint-disable-line
38 | className={ styles.input }
39 | placeholder="search"
40 | />
41 |
42 | );
43 | }
44 |
45 | }
46 |
47 | export default Search;
48 |
--------------------------------------------------------------------------------
/src/components/Detail.css:
--------------------------------------------------------------------------------
1 | @import '../styles/variables.css';
2 |
3 | .container {
4 | margin: 0;
5 | width: 100%;
6 | height: 100%;
7 | background-color: #fff;
8 | overflow: scroll;
9 | position: relative;
10 | }
11 |
12 | /* This may be used for different styling of empty container */
13 | .containerEmpty {
14 | composes: container;
15 | }
16 |
17 | .empty {
18 | position: absolute;
19 | padding: var(--pad);
20 | top: 50%;
21 | left: 50%;
22 | transform: translate(-50%, -50%);
23 | }
24 |
25 | .dataContainer {
26 | display: flex;
27 | flex-wrap: nowrap;
28 | }
29 |
30 | .avatar {
31 | padding: var(--pad);
32 | img {
33 | width: var(--avatarSize);
34 | height: var(--avatarSize);
35 | max-width: 100%;
36 | }
37 | }
38 |
39 | .data {
40 | padding: var(--pad);
41 | h1 {
42 | margin: calc(var(--pad) / 2) 0;
43 | border-bottom: 1px solid #ddd;
44 | }
45 | }
46 |
47 | .tableData {
48 | width: 100%;
49 | th,
50 | td {
51 | vertical-align: baseline;
52 | }
53 | th {
54 | font-weight: normal;
55 | font-size: var(--smallerFontSize);
56 | color: var(--dimmedTextColor);
57 | padding-right: var(--pad);
58 | text-align: right;
59 | }
60 | }
61 |
62 | .separator {
63 | hr {
64 | margin: calc(var(--pad) / 2) 0;
65 | border: 0;
66 | height: 1px;
67 | background-color: var(--lineColor);
68 | }
69 | }
70 |
71 | .link {
72 | color: var(--textColor);
73 | text-decoration: none;
74 | &:hover {
75 | text-decoration: underline;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/docs/connectWith.md:
--------------------------------------------------------------------------------
1 | # Connect with state and HoC (higher order components)
2 |
3 | We choose one of higher order component (`SearchContainer.js`).
4 |
5 | ## Redux way
6 |
7 | ```javascript
8 | import { bindActionCreators } from 'redux';
9 | import { connect } from 'react-redux';
10 | import { setFilter } from '../actions';
11 | import Search from './Search';
12 |
13 | // pretty standard mapping state to props...
14 | const mapStateToProps = (state) => ({
15 | filter: state.filter,
16 | });
17 |
18 | // ... and dispatch to props
19 | // (with help of Redux bindActionCreators), so we don't have
20 | // bother our wrapped component with dispatching
21 | const mapDispatechToProps = (dispatch) => (
22 | bindActionCreators({ setFilter }, dispatch)
23 | );
24 |
25 | const SearchContainer = connect(
26 | mapStateToProps,
27 | mapDispatechToProps
28 | )(Search);
29 |
30 | export default SearchContainer;
31 | ```
32 |
33 | ## RxR way
34 |
35 | ```javascript
36 | import { connectWithState } from 'rxr-react';
37 | import actionStreams from '../actions';
38 | import Search from './Search';
39 |
40 | // the selector is very similar to Redux.
41 | // We can use it to pass the messageStream
42 | // functions too, because we do not need dispatch.
43 | const selector = (state) => ({
44 | filter: state.filter,
45 | setFilter: actionStreams.setFilter,
46 | });
47 |
48 | const SearchContainer = connectWithState(selector)(Search);
49 |
50 | export default SearchContainer;
51 | ```
52 |
53 | This is virtually the same! That's great.
54 |
55 | In RxR it is slightly simpler because we do not need `dispatch`.
56 |
57 | Refactoring our HoC to RxR would be really easy. Hoooray!
58 |
59 | ---
60 |
61 | That's pretty much all.
62 |
63 | But wait, let's say a word [about async ... »](./asyncFetch.md)
64 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | // import Rx from 'rxjs';
2 | import { combineReducers } from 'rxr';
3 | import actionStreams from '../actions';
4 | import {
5 | IS_LOADING,
6 | CLIENTS_DATA_URL,
7 | } from '../utils/constants';
8 | import asyncFetchDataRx from '../utils/asyncFetchDataRx';
9 |
10 | // each reducer is "connected" to corresponding message stream.
11 | // this makes it more straightforward (IMHO)
12 | // the main difference: RxR reducer doesn't return new state but the function
13 | // that may be used to create new state.
14 |
15 | const clientsDataLoadingReducer$ = actionStreams.clientsDataLoading$
16 | .map((ts) => state => ({ ...state, clients: { ...state.clients, status: IS_LOADING, ts } }));
17 |
18 | const setFilterReducer$ = actionStreams.setFilter$
19 | .map((val = '') => state => ({ ...state, filter: val }));
20 |
21 | const selectClientReducer$ = actionStreams.selectClient$
22 | .map((id = '') => state => ({ ...state, selectedClient: id.toString() }));
23 |
24 | const receivedClientsDataReducer$ = actionStreams.receivedClientsData$
25 | .map(({ data, error, ts }) => state => {
26 | if (error) {
27 | const err = typeof error === 'object' ? error.message : error;
28 | return { ...state, clients: { ...state.clients, status: err, ts } };
29 | }
30 | if (Array.isArray(data)) {
31 | return { ...state, clients: { data, status: undefined, ts } };
32 | }
33 | return state;
34 | });
35 |
36 | // we have to use flatMap here because
37 | // the asyncFetchDataRx() function returns Observable.fromPromise
38 | // and it is metastream that we have to flatten
39 | const fetchClientsReducer$ = actionStreams.fetchClients$
40 | .flatMap((url = CLIENTS_DATA_URL) => {
41 | const ts = Date.now();
42 | // notify about the loading
43 | actionStreams.clientsDataLoading$.next(ts);
44 | return asyncFetchDataRx(url);
45 | }).map(val => {
46 | const ts = Date.now();
47 | const error = (val instanceof Error) ? val.message : undefined;
48 | const data = error ? undefined : val;
49 | // update state
50 | actionStreams.receivedClientsData$.next({ data, error, ts });
51 | return (state) => state;
52 | });
53 |
54 | // we combine the reducers to one stream
55 | const reducer$ = combineReducers([
56 | clientsDataLoadingReducer$,
57 | setFilterReducer$,
58 | selectClientReducer$,
59 | receivedClientsDataReducer$,
60 | fetchClientsReducer$
61 | ]);
62 |
63 | export default reducer$;
64 |
--------------------------------------------------------------------------------
/src/components/Detail.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import {
3 | fullname,
4 | AvatarImg,
5 | EmailLink,
6 | } from './utils';
7 | import styles from './Detail.css';
8 |
9 | const Empty = () => (
10 |
11 | Select awesome person from the left...
12 |
13 | );
14 |
15 | const Data = ({ clientData }) => (
16 |
17 |
20 |
21 |
{ fullname(clientData) }
22 |
23 |
24 |
25 | | position: |
26 | { clientData.job.title } |
27 |
28 |
29 | | at: |
30 | { clientData.job.company } |
31 |
32 |
33 |
|
34 |
35 |
36 | | email: |
37 | |
38 |
39 |
40 | | phone: |
41 | { clientData.contact.phone } |
42 |
43 |
44 |
|
45 |
46 |
47 | | address: |
48 |
49 | { clientData.address.street }
50 |
51 | { clientData.address.zipCode }
52 | { clientData.address.city }
53 |
54 | { clientData.address.country }
55 | |
56 |
57 |
58 |
59 |
60 |
61 | );
62 |
63 | Data.propTypes = {
64 | clientData: PropTypes.object,
65 | };
66 |
67 | const Detail = ({ clientData }) => (
68 |
69 | { clientData ? : }
70 |
71 | );
72 |
73 | Detail.propTypes = {
74 | clientData: PropTypes.object,
75 | };
76 |
77 | export default Detail;
78 |
--------------------------------------------------------------------------------
/webpack/webpack.base.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require("webpack");
2 | var path = require("path");
3 | var CopyWebpackPlugin = require('copy-webpack-plugin');
4 | var ExtractTextPlugin = require('extract-text-webpack-plugin');
5 | var HTMLWebpackPlugin = require("html-webpack-plugin");
6 |
7 | var CopyWebpackPluginConf = new CopyWebpackPlugin([{ from: 'assets' }]);
8 | var ExtractTextPluginConf = new ExtractTextPlugin('index.css', { allChunks: true });
9 | var HTMLWebpackPluginConf = new HTMLWebpackPlugin({
10 | template: path.resolve("src/index.html"),
11 | filename: 'index.html', // wasnt in MZ
12 | inject: 'body', // wasnt in MZ
13 | minify: { collapseWhitespace: true }
14 | });
15 |
16 |
17 | var publicBasePath = 'public';
18 |
19 | var config = {
20 | publicBasePath: publicBasePath,
21 |
22 | configuredPlugins: {
23 | CopyWebpackPluginConf,
24 | ExtractTextPluginConf,
25 | HTMLWebpackPluginConf
26 | },
27 |
28 | entry: [
29 | path.resolve("src/index.js"),
30 | ],
31 |
32 | output: {
33 | path: path.resolve(publicBasePath),
34 | publicPath: "/",
35 | filename: "bundle.js",
36 | },
37 |
38 | resolve: {
39 | extensions: ["", ".js"],
40 | alias: {
41 | "app": path.resolve(__dirname, '../src'),
42 | }
43 | },
44 |
45 | plugins: [
46 | HTMLWebpackPluginConf,
47 | ExtractTextPluginConf,
48 | CopyWebpackPluginConf,
49 | ],
50 |
51 | module: {
52 | loaders: [
53 | // { test: /\.jsx?$/, include: [path.resolve(__dirname, 'src')], exclude: /node_modules/, loader: "babel" },
54 | { test: /\.json$/, loader: "json-loader" },
55 | {
56 | test: /\.js$/,
57 | include: [
58 | path.resolve(__dirname, '../src'),
59 | ],
60 | exclude: [
61 | path.resolve(__dirname, '../node_modules/'),
62 | path.resolve(__dirname, '../tests'),
63 | ],
64 | loader: "babel-loader",
65 | },
66 | {
67 | test: /\.css$/,
68 | loader: ExtractTextPluginConf.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]&minimize!postcss')
69 | },
70 | {
71 | test: /\.svg$/,
72 | loader: "url-loader?limit=10000&mimetype=image/svg+xml"
73 | },
74 | ]
75 | },
76 |
77 | postcssPlugins: [
78 | require('postcss-import')({ addDependencyTo: webpack }),
79 | require("postcss-url")(),
80 | require("postcss-cssnext")(),
81 | require("postcss-nested")(),
82 | require('precss'),
83 | ],
84 | };
85 |
86 | module.exports = config;
87 |
--------------------------------------------------------------------------------
/webpack/webpack.dev.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require("webpack");
2 | var config = require("./webpack.base.config.js");
3 | var extend = require("extend");
4 | var path = require("path");
5 |
6 | devConfig = {
7 | debug: true,
8 |
9 | // devtool: "inline-source-map",
10 | devtool: 'cheap-module-source-map',
11 |
12 | entry: [
13 | "webpack-dev-server/client?http://localhost:8080",
14 | // "webpack/hot/only-dev-server"
15 | ].concat(config.entry),
16 |
17 | output: extend(config.output, { publicPath: "http://localhost:8080/" }),
18 |
19 | resolve: config.resolve,
20 |
21 | // plugins: [
22 | // new webpack.HotModuleReplacementPlugin(),
23 | // new webpack.NoErrorsPlugin(),
24 | // ].concat(config.plugins),
25 | plugins: config.plugins,
26 |
27 | // module: {
28 | // loaders: config.module.loaders,
29 | // // loaders: [
30 | // // {
31 | // // test: /\.js$/,
32 | // // include: [
33 | // // path.resolve(__dirname, '../', 'src'),
34 | // // ],
35 | // // exclude: [
36 | // // /node_modules/,
37 | // // path.resolve(__dirname, '..', 'tests'),
38 | // // ],
39 | // // loaders: ['react-hot', 'babel-loader'],
40 | // // },
41 | // // // {
42 | // // // test: /\.css$/,
43 | // // // loader: 'style!css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]&&sourceMap!postcss',
44 | // // // },
45 | // // // { test: /\.css$/, loader: "style!css?module&sourceMap!postcss" },
46 | // // ].concat(config.module.loaders),
47 | // },
48 | module: {
49 | loaders: [
50 | // { test: /\.jsx?$/, include: [path.resolve(__dirname, 'src')], exclude: /node_modules/, loader: "babel" },
51 | { test: /\.json$/, loader: "json-loader" },
52 | {
53 | test: /\.js$/,
54 | include: [
55 | path.resolve(__dirname, '../src'),
56 | ],
57 | exclude: [
58 | path.resolve(__dirname, '../node_modules/'),
59 | path.resolve(__dirname, '../tests'),
60 | ],
61 | loader: "babel-loader",
62 | },
63 | {
64 | test: /\.css$/,
65 | loader: config.configuredPlugins.ExtractTextPluginConf.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]&minimize!postcss')
66 | },
67 | {
68 | test: /\.svg$/,
69 | loader: "url-loader?limit=10000&mimetype=image/svg+xml"
70 | },
71 | ]
72 | },
73 |
74 | postcss: function (webpack) {
75 | return config.postcssPlugins.concat([
76 | require("postcss-browser-reporter")(),
77 | require("postcss-reporter")(),
78 | ]);
79 | }
80 |
81 | };
82 |
83 | module.exports = devConfig;
84 |
--------------------------------------------------------------------------------
/src/components/ListContainer.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes, Component } from 'react';
2 | import { connectWithState } from 'rxr-react';
3 | import actionStreams from '../actions';
4 | import { findInObj } from '../utils';
5 | import { IS_LOADING } from '../utils/constants';
6 | import List from './List';
7 | import styles from './ListContainer.css';
8 |
9 | const Message = ({ message }) => (
10 | { message }
11 | );
12 |
13 | Message.propTypes = {
14 | message: PropTypes.string,
15 | };
16 |
17 | class ListComponent extends Component {
18 | static propTypes = {
19 | fetchClients: PropTypes.func,
20 | selectClient: PropTypes.func,
21 | clients: PropTypes.object,
22 | selectedClient: PropTypes.string,
23 | filter: PropTypes.string,
24 | }
25 |
26 | // instead of loading data in the main app,
27 | // it's better to load it here, because this is the component,
28 | // that uses them. Again, better to use this app inside bigger one.
29 | componentWillMount() {
30 | this.props.fetchClients();
31 | this.listToDisplay = this.filterList(
32 | this.props.clients.data,
33 | this.props.filter,
34 | this.props.selectedClient
35 | );
36 | }
37 |
38 | componentWillReceiveProps(nextProps) {
39 | this.listToDisplay = this.filterList(
40 | nextProps.clients.data,
41 | nextProps.filter,
42 | nextProps.selectedClient
43 | );
44 | }
45 |
46 | listToDisplay;
47 |
48 | isItLoading() {
49 | return this.props.clients.status === IS_LOADING;
50 | }
51 |
52 | isItError() {
53 | return !!this.props.clients.status;
54 | }
55 |
56 | filterList(clientsData, filter, selectedClient) {
57 | if (!filter) return clientsData;
58 | const filteredList = clientsData.filter(i => findInObj(i, filter));
59 | if (
60 | selectedClient
61 | && filteredList.filter(i => i._id === selectedClient).length < 1
62 | ) this.props.selectClient('');
63 | return filteredList;
64 | }
65 |
66 | render() {
67 | if (this.isItLoading()) {
68 | return ;
69 | }
70 | if (this.isItError()) {
71 | return ;
72 | }
73 | if (this.listToDisplay.length === 0) {
74 | return ;
75 | }
76 |
77 | return (
78 |
85 | );
86 | }
87 | }
88 |
89 | const selector = (state) => ({
90 | clients: state.clients,
91 | selectedClient: state.selectedClient,
92 | filter: state.filter,
93 | fetchClients: actionStreams.fetchClients,
94 | selectClient: actionStreams.selectClient,
95 | });
96 |
97 | const ListContainer = connectWithState(selector)(ListComponent);
98 |
99 | export default ListContainer;
100 |
--------------------------------------------------------------------------------
/docs/appIndex.md:
--------------------------------------------------------------------------------
1 | # App mail file (index.js)
2 |
3 | ## Redux way
4 |
5 | ```javascript
6 | import React from 'react';
7 | import { render } from 'react-dom';
8 | import { Provider } from 'react-redux';
9 | import { createStore, applyMiddleware } from 'redux';
10 | // this is for async loading
11 | import thunkMiddleware from 'redux-thunk';
12 | // we use css modules
13 | import styles from './index.css';
14 |
15 | // our reducers
16 | import reducers from './reducers';
17 |
18 | // and the usual createStore
19 | const store = createStore(
20 | reducers,
21 | applyMiddleware(
22 | thunkMiddleware
23 | )
24 | );
25 |
26 | import App from './components/App';
27 |
28 | render((
29 |
30 |
31 | )
32 | , document.getElementById('index')
33 | );
34 | ```
35 |
36 | ## RxR way
37 |
38 | ```javascript
39 | import React from 'react';
40 | import { render } from 'react-dom';
41 | import { Provider } from 'rxr-react';
42 | import { createState, createLoggerStream, startLogging, messageStreamsMonitor$ } from 'rxr';
43 |
44 | import styles from './index.css';
45 |
46 | import App from './components/App';
47 |
48 | // our RxR reducers
49 | import reducer$ from './reducers';
50 |
51 | // we create initial state here
52 | const initialState = {
53 | clients: { data: [], ts: 0, status: undefined },
54 | filter: '',
55 | selectedClient: '',
56 | };
57 |
58 | // and because in RxR is no need of store, we create state directly
59 | const state$ = createState(reducer$, initialState);
60 |
61 | // we will log all state changes and messageStreams events to console
62 | const loggerStream$ = createLoggerStream(state$, messageStreamsMonitor$);
63 | startLogging(loggerStream$);
64 |
65 | // RxR-React provides similar Provider component as React-Redux
66 | render(
67 |
68 |
69 | , document.getElementById('index')
70 | );
71 |
72 | ```
73 |
74 | _**Q:** Why are some variables here with '$' on the end?_
75 |
76 | _**A:** Some use this as a convention to signalize that this value stores Observable stream. We will use it here, too._
77 |
78 | The structure is pretty the similar.
79 |
80 | In RxR there is no need for `thunkMiddleware`, because RxJS (what is behind RxR) has a pretty nice async handling.
81 |
82 | In Redux initial state is created within reducers.
83 |
84 | In RxR (as you can learn from [RxR gitbook](https://dacz.github.io/rxr/)) the reducer doesn't produce new state (as in Redux) but in RxR it produces function how to make this new state (from previous state). Don't worry if it is puzzling for now (it is explained in the [ebook in detail](https://dacz.github.io/rxr/)).
85 |
86 | Redux creates store. Store is container with state and dispatch (and little bit more).
87 |
88 | RxR creates state stream. It is `Rx.Observable`. It is like pipe that gives you new value every time the state changes. Redux store has `getState` that gives you current state when you need it.
89 |
90 | We are passing (store or state$ via React context to the app. Same. Nice.
91 |
92 | ---
93 |
94 | Usually first you create **actions** in Redux (or **message streams** in RxR) so [let's look at them ... »](./actions.md)
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rxr-redux-example",
3 | "version": "0.1.0",
4 | "description": "RxR and Redux side by side to learn the principles.",
5 | "main": "dist/index.html",
6 | "scripts": {
7 | "clean": "rimraf public && mkdir public",
8 | "lint": "eslint src test",
9 | "test": "cross-env NODE_ENV=test ava",
10 | "test:watch": "npm test -- --watch -v",
11 | "check:src": "npm run lint && npm run test",
12 | "dev": "node server/webpack-dev-server",
13 | "build:client": "cross-env NODE_ENV=production webpack --config webpack/webpack.prod.config.js -p",
14 | "build": "cross-env NODE_ENV=production npm run clean && npm run build:client",
15 | "start": "node server/server.js"
16 | },
17 | "author": "dacz",
18 | "license": "MIT",
19 | "ava": {
20 | "files": [
21 | "test/**/test-*.js"
22 | ],
23 | "require": [
24 | "babel-register",
25 | "ignore-styles"
26 | ],
27 | "babel": {
28 | "babelrc": true
29 | }
30 | },
31 | "engines": {
32 | "node": "6.x.x",
33 | "npm": "3.x.x"
34 | },
35 | "repository": {
36 | "type": "git",
37 | "url": "https://github.com/dacz/rxr-redux-example.git"
38 | },
39 | "keywords": [
40 | "rxjs",
41 | "reactive",
42 | "redux",
43 | "rxr",
44 | "reducer",
45 | "state",
46 | "react"
47 | ],
48 | "bugs": {
49 | "url": "https://github.com/dacz/rxr-redux-example/issues"
50 | },
51 | "homepage": "https://github.com/dacz/rxr-redux-example",
52 | "devDependencies": {
53 | "ava": "^0.15.2",
54 | "babel": "^6.5.2",
55 | "babel-cli": "^6.8.0",
56 | "babel-core": "^6.7.4",
57 | "babel-eslint": "^6.0.2",
58 | "babel-loader": "^6.2.4",
59 | "babel-plugin-transform-runtime": "^6.9.0",
60 | "babel-preset-es2015": "^6.6.0",
61 | "babel-preset-react": "^6.5.0",
62 | "babel-preset-stage-0": "^6.5.0",
63 | "copy-webpack-plugin": "^3.0.1",
64 | "cross-env": "^2.0.0",
65 | "css-loader": "^0.23.1",
66 | "deep-freeze": "0.0.1",
67 | "enzyme": "^2.4.1",
68 | "eslint": "^3.1.1",
69 | "eslint-config-dacz": "^0.2.0",
70 | "eslint-plugin-import": "^1.11.0",
71 | "eslint-plugin-jsx-a11y": "^2.0.1",
72 | "eslint-plugin-react": "^5.2.2",
73 | "extend": "^3.0.0",
74 | "extract-text-webpack-plugin": "^1.0.1",
75 | "html-webpack-plugin": "^2.15.0",
76 | "ignore-styles": "^4.0.0",
77 | "jsdom": "^9.4.1",
78 | "postcss": "^5.1.0",
79 | "postcss-browser-reporter": "^0.5.0",
80 | "postcss-cssnext": "^2.7.0",
81 | "postcss-import": "^8.1.2",
82 | "postcss-loader": "^0.9.1",
83 | "postcss-reporter": "^1.4.1",
84 | "postcss-url": "^5.1.2",
85 | "precss": "^1.4.0",
86 | "rimraf": "^2.5.3",
87 | "style-loader": "^0.13.1",
88 | "stylelint": "^7.0.3",
89 | "stylelint-config-standard": "^11.0.0",
90 | "webpack": "^1.13.1",
91 | "webpack-dev-server": "^1.14.1"
92 | },
93 | "dependencies": {
94 | "babel-polyfill": "^6.9.1",
95 | "babel-runtime": "^6.9.2",
96 | "express": "^4.14.0",
97 | "is-observable": "^0.2.0",
98 | "isomorphic-fetch": "^2.2.1",
99 | "react": "^15.2.1",
100 | "react-dom": "^15.2.1",
101 | "rxjs": "^5.0.0-beta.10",
102 | "rxjs-es": "^5.0.0-beta.10",
103 | "rxr": "^0.2.0",
104 | "rxr-react": "^0.1.1"
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/docs/asyncFetch.md:
--------------------------------------------------------------------------------
1 | # Async & Fetch
2 |
3 | One of the usual way how to handle async fetch in Redux is using Dan Abramov `thunkMiddleware`. It is handled by action creator that doesn't return object but function that is caught by this middleware before reducers and processed.
4 |
5 | In my opinion this is the one "irregularity" I do not like on Redux too much. It feels like a patch for me. Please don't take me wrong - it is just my feel, I do not want to tell you that it is like that. Even I consider it as a patch, it is brilliant patch.
6 |
7 | ## RxR (or RxJS)
8 |
9 | In RxR we are not dispatching messages, we are just putting them into the proper message stream and we suppose that in the end we will get the (pure) function how to modify the state. Time doesn't matter here... so any part of the stream may simply wait for promise to be resolved and then produce the function and pass it further. This is amazing on the streams (and ReactiveX).
10 |
11 | So in this example in RxR we use `asyncFetchDataRx` helper:
12 |
13 | ```javascript
14 | // asyncFetchDataRx (please, don't complain about the stupid name :)
15 | import Rx from 'rxjs';
16 | import 'rxjs/add/operator/catch';
17 | import fetch from 'isomorphic-fetch';
18 | import {
19 | CLIENTS_DATA_URL,
20 | } from './constants';
21 |
22 | /**
23 | * Async data loading
24 | *
25 | * @param {[string]} url to load from
26 | * @return {[stream]} Observable stream of data
27 | */
28 | const asyncFetchDataRx = (url = CLIENTS_DATA_URL) => (
29 | Rx.Observable.fromPromise(fetch(url))
30 | .flatMap(response => Rx.Observable.fromPromise(response.json()))
31 | .catch(err => Rx.Observable.of(new Error(err)))
32 | );
33 |
34 | export default asyncFetchDataRx;
35 | ```
36 |
37 | It's very similar with approach with promises (see as you can see in [Redux example within `actions`](./actions.md)).
38 |
39 | Let's go step by step.
40 |
41 | It is function that gets url to be fetched and returns `Rx.Observable.fromPromise` (chained twice because we get promise to be fetched and then promise to be json parsed). It's pretty similar to `fetch(url).then(response => response.parse()).then(json => ... do something with json)` chain.
42 |
43 | Because `fromPromise` creates another stream, it has to be `flatMap`ed to current stream. Puzzling. Little bit but you will get into it.
44 |
45 | This helper returns again `Observable.fromPromise` and that's why we have to use in the corresponding "reducer" stream the `flatMap`, too, not usual `map`.
46 |
47 | ```javascript
48 | // ...
49 | actionStreams.fetchClients$
50 | .flatMap((url = CLIENTS_DATA_URL) => {
51 | const ts = Date.now();
52 | // notify about the loading
53 | actionStreams.clientsDataLoading$.next(ts);
54 | return asyncFetchDataRx(url);
55 | // ...
56 | ```
57 |
58 | Rx offers us more benefits, like possibility to retry fetch and more. So the `asyncFetchDataRx` may be extended like:
59 |
60 | ```javascript
61 | const asyncFetchDataRx = (url = CLIENTS_DATA_URL) => (
62 | Rx.Observable.fromPromise(fetch(url))
63 | .retryWhen(err$ => err$.delay(1000).take(10))
64 | .flatMap(response => Rx.Observable.fromPromise(response.json()))
65 | .catch(err => Rx.Observable.of(new Error(err)))
66 | );
67 | ```
68 |
69 | It will try 10 times with 1000 milliseconds delays. No special coding required.
70 |
71 | RxJS has one disadvatage - it is **huge** and it discourages you to learn it. But you can start slowly and using just a couple operators. As you will get into it, your code will be cleaner.
72 |
73 | Look at [RxMarbles](http://rxmarbles.com/). They are great. They cover just a small part of all operators - it's like 2 minutes trailer of full movie. You will recognize highly useful functions like `debounce`, `take,` `skipUntil`... just to name some of them.
74 |
75 | ---
76 |
77 | What next? Try to rewrite one of your current Redux app in RxR and RxR-Redux ;)
78 |
79 | Read the [gitbook about RxR](https://dacz.github.io/rxr/).
80 |
--------------------------------------------------------------------------------
/docs/actions.md:
--------------------------------------------------------------------------------
1 | # Actions
2 |
3 | ## Redux first
4 |
5 | We have actions constants:
6 |
7 | ```javascript
8 | // action-constants.js
9 | export const RECEIVED_INITIAL_CLIENTS_DATA = 'RECEIVED_DATA';
10 | export const SET_FILTER = 'SET_FILTER';
11 | export const SELECT_CLIENT = 'SELECT_CLIENT';
12 | export const CLIENTS_DATA_LOADING = 'CLIENTS_DATA_LOADING';
13 | ```
14 |
15 | and then the actions creators
16 |
17 | ```javascript
18 | import fetch from 'isomorphic-fetch';
19 | // we want to be organized and have order in constants, right?
20 | import {
21 | RECEIVED_INITIAL_CLIENTS_DATA,
22 | SET_FILTER,
23 | SELECT_CLIENT,
24 | CLIENTS_DATA_LOADING,
25 | } from './actions-constants';
26 | import {
27 | OUTDATED_LOADING,
28 | CLIENTS_DATA_URL,
29 | } from '../utils/constants';
30 |
31 | export const receivedInitialClientsData = (json, err) => (
32 | {
33 | type: RECEIVED_INITIAL_CLIENTS_DATA,
34 | payload: json,
35 | error: err,
36 | ts: Date.now(),
37 | }
38 | );
39 |
40 | export const clientsDataLoading = () => (
41 | {
42 | type: CLIENTS_DATA_LOADING,
43 | ts: Date.now(),
44 | }
45 | );
46 |
47 | export const setFilter = (str) => (
48 | {
49 | type: SET_FILTER,
50 | payload: str,
51 | }
52 | );
53 |
54 | export const selectClient = (id) => (
55 | {
56 | type: SELECT_CLIENT,
57 | payload: id,
58 | }
59 | );
60 |
61 | // ---- ASYNC ----
62 |
63 | const fetchData = (dispatch) => fetch(CLIENTS_DATA_URL)
64 | .then(response => response.json())
65 | .then(json => dispatch(receivedInitialClientsData(json)))
66 | .catch(err => dispatch(receivedInitialClientsData(undefined, err)));
67 |
68 | export const fetchClients = () => (dispatch, getState) => {
69 | if (Date.now() - getState().clients.ts < OUTDATED_LOADING) return Promise.resolve(); //1
70 | dispatch(clientsDataLoading());
71 | return fetchData(dispatch);
72 | };
73 | ```
74 |
75 | First four action creators are straightforward.
76 |
77 | Then here is the async action creator `fetchClients` that doesn't return object but function. This is where atypical behavior appears within the standard action flow in Redux. This is the moment where the `thunkMiddleware` steps in and catch this action before it reaches reducers and process it. Here it decides if it is needed to load the data (//1), then dispatch the standard action `clientsDataLoading` to signalize that the data are loading and actually fetch the data. After data arrive (and are parsed ok), the standard action `receivedInitialClientsData` is dispatched.
78 |
79 | The async function (thunk) needs the `dispatch` so the can call it.
80 |
81 |
82 | ## The RxR actions
83 |
84 | ```javascript
85 | import { createMessageStreams } from 'rxr';
86 |
87 | const actionStreams = createMessageStreams([
88 | 'clientsDataLoading',
89 | 'setFilter',
90 | 'selectClient',
91 | 'receivedClientsData',
92 | 'fetchClients'
93 | ]);
94 |
95 | export default actionStreams;
96 | ```
97 |
98 | Where is the async logic and actions formats? And what it creates?
99 |
100 | What it does is [described in detail here](https://dacz.github.io/rxr/docs/basics/ActionsStreams.html).
101 |
102 | Basically - `actionStreams` is an object that contains pairs of keys like `clientsDataLoading$` and `clientsDataLoading` ... for all items from the array passed into `createMessageStreams`.
103 |
104 | The `clientsDataLoading$` is `Rx.Subject` that means that it is observer (you can put/pipe something into it) and at the same time is it observable - anything that will subscribe to it's values will get this "something" piped into it.
105 |
106 | Because the way how to put new value into Subject is to call `clientsDataLoading$.next(value)`, the second key created by `createMessageStreams` for this item is plain `clientsDataLoading`. This is just syntactic sugar - when you call `clientsDataLoading(value)`, it is the same as calling the `clientsDataLoading$.next(value)`.
107 |
108 | So in the `actionStreams` is 10 keys ... 5 pairs for each item of the array.
109 |
110 | Simple. And you can clearly see in this file all action creators ... or in RxR **message streams**.
111 |
112 | Why it doesn't describe messages like Redux - with action.type etc?
113 |
114 | Again ... [details here](https://dacz.github.io/rxr/docs/basics/ActionsStreams.html) but shortly - because in RxR the messages doesn't travel to every reducer, but they are passed to the right message stream (aka pipe) directly. Stream of `selectClients` data for example (like 'selected A', then 'selected R', ...).
115 |
116 | Less typing in RxR and nice overview of all message streams (aka action creators from Redux).
117 |
118 | Let's go on.
119 |
120 | ---
121 |
122 | Interested in reducers? [Here they are... »](./reducer.md)
123 |
--------------------------------------------------------------------------------
/docs/reducer.md:
--------------------------------------------------------------------------------
1 | # Reducer
2 |
3 | ## Redux way first again...
4 |
5 | Keep in mind - Redux reducer is pure function.
6 |
7 | Actually we have 3 reducers and we split them into 3 files:
8 |
9 | ```javascript
10 | // filter.js
11 | import { SET_FILTER } from '../actions/actions-constants';
12 |
13 | const filter = (state = '', action) => {
14 | if (!action || !action.type) return state;
15 | switch (action.type) {
16 | case SET_FILTER: return action.payload;
17 | default: return state;
18 | }
19 | };
20 |
21 | export default filter;
22 | ```
23 |
24 | ```javascript
25 | //selectedClient.js
26 | import { SELECT_CLIENT } from '../actions/actions-constants';
27 |
28 | const selectedClient = (state = '', action) => {
29 | if (!action || !action.type) return state;
30 | switch (action.type) {
31 | case SELECT_CLIENT: {
32 | const payload = !!action.payload ? action.payload : '';
33 | if (typeof payload === 'string') return payload;
34 | return state;
35 | }
36 | default: return state;
37 | }
38 | };
39 |
40 | export default selectedClient;
41 | ```
42 |
43 | and
44 |
45 | ```javascript
46 | // clients.js
47 | import {
48 | RECEIVED_INITIAL_CLIENTS_DATA,
49 | CLIENTS_DATA_LOADING,
50 | } from '../actions/actions-constants';
51 | import {
52 | IS_LOADING,
53 | } from '../utils/constants';
54 |
55 | const clients = (state, action) => {
56 | state = state || {
57 | data: [],
58 | status: undefined,
59 | };
60 | if (!action || !action.type) return state;
61 |
62 | switch (action.type) {
63 | case RECEIVED_INITIAL_CLIENTS_DATA: {
64 | if (action.error) {
65 | const err = typeof action.error === 'object' ? action.error.message : action.error;
66 | return { ...state, status: err, ts: action.ts };
67 | }
68 | if (Array.isArray(action.payload)) {
69 | return { ...state, clients: { data, status: undefined, ts: action.ts } };
70 | }
71 | return state;
72 | }
73 | case CLIENTS_DATA_LOADING: return { ...state, status: IS_LOADING, ts: action.ts };
74 | default: return state;
75 | }
76 | };
77 |
78 | export default clients;
79 | ```
80 |
81 | and making one reducer
82 |
83 | ```javascript
84 | // reducers/index.js
85 | import { combineReducers } from 'redux';
86 | import clients from './clients';
87 | import filter from './filter';
88 | import selectedClient from './selectedClient';
89 |
90 | const reducer = combineReducers({
91 | clients,
92 | filter,
93 | selectedClient,
94 | });
95 |
96 | export default reducer;
97 | ```
98 |
99 | State is immutable. We use object spread operator (thanks Babel) to make it easier.
100 |
101 | Every Redux reducer define initial state. This is difference from RxR - we defined initial state in `index.js`.
102 |
103 | Every reducer has some overhead with all the `switch` statements to find the corresponding action that belongs to them.
104 |
105 |
106 | ## RxR way
107 |
108 | We put it in just one file:
109 |
110 | ```javascript
111 | import { combineReducers } from 'rxr';
112 | import actionStreams from '../actions';
113 | import {
114 | IS_LOADING,
115 | CLIENTS_DATA_URL,
116 | } from '../utils/constants';
117 | import asyncFetchDataRx from '../utils/asyncFetchDataRx';
118 |
119 | // each reducer is "connected" to corresponding message stream.
120 | // this makes it more straightforward (IMHO)
121 | // the main difference: RxR reducer doesn't return new state but the function
122 | // that may be used to create new state.
123 |
124 | const clientsDataLoadingReducer$ = actionStreams.clientsDataLoading$
125 | .map((ts) => state => ({ ...state, clients: { ...state.clients, status: IS_LOADING, ts } }));
126 |
127 | const setFilterReducer$ = actionStreams.setFilter$
128 | .map((val = '') => state => ({ ...state, filter: val }));
129 |
130 | const selectClientReducer$ = actionStreams.selectClient$
131 | .map((id = '') => state => ({ ...state, selectedClient: id.toString() }));
132 |
133 | const receivedClientsDataReducer$ = actionStreams.receivedClientsData$
134 | .map(({ data, error, ts }) => state => {
135 | if (error) {
136 | const err = typeof error === 'object' ? error.message : error;
137 | return { ...state, clients: { ...state.clients, status: err, ts } };
138 | }
139 | if (Array.isArray(data)) {
140 | return { ...state, clients: { data, status: undefined, ts } };
141 | }
142 | return state;
143 | });
144 |
145 | const fetchClientsReducer$ = actionStreams.fetchClients$
146 | .flatMap((url = CLIENTS_DATA_URL) => {
147 | const ts = Date.now();
148 | // notify about the loading
149 | actionStreams.clientsDataLoading$.next(ts);
150 | return asyncFetchDataRx(url);
151 | }).map(val => {
152 | const ts = Date.now();
153 | const error = (val instanceof Error) ? val.message : undefined;
154 | const data = error ? undefined : val;
155 | // update state
156 | actionStreams.receivedClientsData$.next({ data, error, ts });
157 | return (state) => state;
158 | });
159 |
160 |
161 | // we combine the reducers to one stream
162 | const reducer$ = combineReducers([
163 | clientsDataLoadingReducer$,
164 | setFilterReducer$,
165 | selectClientReducer$,
166 | receivedClientsDataReducer$,
167 | fetchClientsReducer$
168 | ]);
169 |
170 | export default reducer$;
171 | ```
172 | ### What is the output?
173 |
174 | First significant difference is that RxR reducers (if we will use this name) are **connected directly to corresponding message stream**. We do not need the `switch` statements. Consider it as another transformation function on the stream (chain/pipe/you name it). This difference helps us to tidy up corresponding code comparing to Redux.
175 |
176 | Second difference is that transformation within RxR reducer doesn't return new state. It makes sense - all the stream still doesn't know the current state. **It returns the function that takes state as an argument and creates new state.** It may seem to be difficult but have a look at the corresponding code in Redux reducer and RxR reducer stream. They are logically identical (with one small difference as we will see). This is huge benefit when we want to rewrite the app. No brand new functions or concepts.
177 |
178 | ### State parts
179 |
180 | **Significant difference is scoping the state**. Each Redux reducer manages it's own part of the state. It doesn't know about the whole state structure. This is big benefit of Redux - it allows to compose. On the other side RxR reducer have to work with the whole state structure. I want to figure out the way how to make it (optionally) composable the same way as Redux.
181 |
182 | On the other side this may be considered as benefit. One disadvantage od Redux "sub-stating" is that it is not trivial to use libraries as Immutable (to manage the whole state as one Immutable object). With RxR it is easy, because we are still working with the whole state object (so far functions that modify the whole state object).
183 |
184 | Let's see what does it mean:
185 |
186 | In Redux you can see corresponding state changing function eg. in `filter.js` reducer:
187 |
188 | ```javascript
189 | // ...
190 | case SET_FILTER: return action.payload;
191 | // ...
192 | ```
193 |
194 | Because this reducer manages just simple state - value of the filter.
195 |
196 | In RxR, the similar part is:
197 |
198 | ```javascript
199 | //...
200 | actionStreams.setFilter$.map((val = '') => state => ({ ...state, filter: val }));
201 | //...
202 | ```
203 |
204 | As you can see we have to use spread object operator to modify only filter part. It's not a big deal I think. It may become more difficult if you use deeper nesting of Redux reducers (but honestly - are you able to keep mental model of it in your head? And figure out how to use Immutable?)
205 |
206 | ### Pure?
207 |
208 | Let's look into last difference. Redux defines reducer as a strictly pure function. Our "reducer-like" approach is not pure for 2 reasons. One is using `Date.now()` within the code. Ok, we could manage it by requiring the timestamp be part of the input into the message stream. Like you would require `ts` as an argument to the `receivedClientsData` stream together with data. But it doesn't solve it - this is again part of the reducer stream. Redux solved it putting it into action creator. They have not be pure. Why they have not and reducer have to? It's just the convention.
209 |
210 | We can be "clever" and say: we do nothing against the Redux principles. Is it a lie? No. Citation from [Redux documentation](http://redux.js.org/docs/introduction/ThreePrinciples.html):
211 |
212 | > Changes are made with pure functions
213 |
214 | True is we are **not making changes to the state** in our reducer (maybe we should stop using name out RxR counterpart "reducer"). Yes - we are just **producing function** that will ultimately **do the change of the state** (create new state, to be correct). And we can put the same convention to our functions like is in Redux - the functions we are producing in this reducer-like stream transformation (we have to find better name :)) are pure. Because they **are the same like we do in Redux example!**
215 |
216 | ---
217 |
218 | What next? Let's have a [short look at the `createState` we have in the main file `index.js` ... »](./createState.md)
219 |
--------------------------------------------------------------------------------
/assets/clients.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "general": {
4 | "firstName": "Liana",
5 | "lastName": "Crooks",
6 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/kevinoh/128.jpg"
7 | },
8 | "job": {
9 | "company": "Ledner, Johnson and Predovic",
10 | "title": "Investor Functionality Coordinator"
11 | },
12 | "contact": {
13 | "email": "Gerry_Hackett77@gmail.com",
14 | "phone": "(895) 984-0132"
15 | },
16 | "address": {
17 | "street": "1520 Zemlak Cove",
18 | "city": "New Devon",
19 | "zipCode": "42586-7898",
20 | "country": "Guinea-Bissau"
21 | },
22 | "_id": "BylHCB6v"
23 | },
24 | {
25 | "general": {
26 | "firstName": "Deontae",
27 | "lastName": "Dare",
28 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/andysolomon/128.jpg"
29 | },
30 | "job": {
31 | "company": "D'Amore, Dicki and Borer",
32 | "title": "International Applications Consultant"
33 | },
34 | "contact": {
35 | "email": "Kellie.Marvin38@yahoo.com",
36 | "phone": "1-615-843-3426 x600"
37 | },
38 | "address": {
39 | "street": "65901 Glover Terrace",
40 | "city": "Alden ton",
41 | "zipCode": "57744-4248",
42 | "country": "Kenya"
43 | },
44 | "_id": "BJxgS0H6v"
45 | },
46 | {
47 | "general": {
48 | "firstName": "Cortez",
49 | "lastName": "Pacocha",
50 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/holdenweb/128.jpg"
51 | },
52 | "job": {
53 | "company": "McKenzie Group",
54 | "title": "Forward Branding Developer"
55 | },
56 | "contact": {
57 | "email": "Sage_Wiegand@hotmail.com",
58 | "phone": "725.583.0926 x0430"
59 | },
60 | "address": {
61 | "street": "376 Reginald Dam",
62 | "city": "Port Enid fort",
63 | "zipCode": "51294-8361",
64 | "country": "Belarus"
65 | },
66 | "_id": "rJWeH0Bav"
67 | },
68 | {
69 | "general": {
70 | "firstName": "Geoffrey",
71 | "lastName": "Russel",
72 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/swaplord/128.jpg"
73 | },
74 | "job": {
75 | "company": "Nienow and Sons",
76 | "title": "Central Intranet Designer"
77 | },
78 | "contact": {
79 | "email": "Daron.Bartoletti9@gmail.com",
80 | "phone": "646.580.9390"
81 | },
82 | "address": {
83 | "street": "5050 Iva Extensions",
84 | "city": "Madonna burgh",
85 | "zipCode": "74470-6362",
86 | "country": "Fiji"
87 | },
88 | "_id": "SJfxSAraP"
89 | },
90 | {
91 | "general": {
92 | "firstName": "Christian",
93 | "lastName": "Wuckert",
94 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/lowie/128.jpg"
95 | },
96 | "job": {
97 | "company": "Jakubowski Inc",
98 | "title": "Future Branding Assistant"
99 | },
100 | "contact": {
101 | "email": "Zechariah48@hotmail.com",
102 | "phone": "555-516-5564"
103 | },
104 | "address": {
105 | "street": "1946 Nolan Mountain",
106 | "city": "Garnet stad",
107 | "zipCode": "79438",
108 | "country": "Puerto Rico"
109 | },
110 | "_id": "B1mlSCH6w"
111 | },
112 | {
113 | "general": {
114 | "firstName": "Joana",
115 | "lastName": "Breitenberg",
116 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/themrdave/128.jpg"
117 | },
118 | "job": {
119 | "company": "Jacobson - Fay",
120 | "title": "Global Factors Officer"
121 | },
122 | "contact": {
123 | "email": "Jaylon92@hotmail.com",
124 | "phone": "202.387.0215 x7568"
125 | },
126 | "address": {
127 | "street": "3446 Isabelle Shore",
128 | "city": "Port Kayli",
129 | "zipCode": "63713-9923",
130 | "country": "Switzerland"
131 | },
132 | "_id": "SJVlBRBTw"
133 | },
134 | {
135 | "general": {
136 | "firstName": "Elton",
137 | "lastName": "Pfannerstill",
138 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/abdots/128.jpg"
139 | },
140 | "job": {
141 | "company": "Franecki LLC",
142 | "title": "Product Applications Assistant"
143 | },
144 | "contact": {
145 | "email": "Tomasa26@hotmail.com",
146 | "phone": "168.457.7936 x4319"
147 | },
148 | "address": {
149 | "street": "1975 Creola Streets",
150 | "city": "South Favian",
151 | "zipCode": "65666-6266",
152 | "country": "Afghanistan"
153 | },
154 | "_id": "rkrxH0Bpv"
155 | },
156 | {
157 | "general": {
158 | "firstName": "Alvena",
159 | "lastName": "Paucek",
160 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/dvdwinden/128.jpg"
161 | },
162 | "job": {
163 | "company": "Goldner - Haag",
164 | "title": "Corporate Interactions Assistant"
165 | },
166 | "contact": {
167 | "email": "Oran66@yahoo.com",
168 | "phone": "(754) 491-0343 x6060"
169 | },
170 | "address": {
171 | "street": "95820 Bud Trail",
172 | "city": "West Randy furt",
173 | "zipCode": "98923",
174 | "country": "French Polynesia"
175 | },
176 | "_id": "H1LgS0Spv"
177 | },
178 | {
179 | "general": {
180 | "firstName": "Lew",
181 | "lastName": "Daniel",
182 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/dhoot_amit/128.jpg"
183 | },
184 | "job": {
185 | "company": "Corwin LLC",
186 | "title": "Product Research Liason"
187 | },
188 | "contact": {
189 | "email": "Cordelia.Bartoletti28@gmail.com",
190 | "phone": "142.115.7141 x943"
191 | },
192 | "address": {
193 | "street": "04363 Torphy Club",
194 | "city": "East Heidi",
195 | "zipCode": "10926-2413",
196 | "country": "Estonia"
197 | },
198 | "_id": "r1verRr6v"
199 | },
200 | {
201 | "general": {
202 | "firstName": "Darlene",
203 | "lastName": "Davis",
204 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/raphaelnikson/128.jpg"
205 | },
206 | "job": {
207 | "company": "Franecki and Sons",
208 | "title": "Internal Functionality Supervisor"
209 | },
210 | "contact": {
211 | "email": "Erich36@gmail.com",
212 | "phone": "(546) 314-2504"
213 | },
214 | "address": {
215 | "street": "1513 Kessler Crossing",
216 | "city": "South Randi fort",
217 | "zipCode": "60194",
218 | "country": "Malta"
219 | },
220 | "_id": "HJdxSCS6w"
221 | },
222 | {
223 | "general": {
224 | "firstName": "Savannah",
225 | "lastName": "Predovic",
226 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/mtolokonnikov/128.jpg"
227 | },
228 | "job": {
229 | "company": "O'Connell - Koepp",
230 | "title": "International Integration Liason"
231 | },
232 | "contact": {
233 | "email": "Torey_Stroman@hotmail.com",
234 | "phone": "324.808.6122"
235 | },
236 | "address": {
237 | "street": "660 Lueilwitz Island",
238 | "city": "East Okey port",
239 | "zipCode": "26277",
240 | "country": "Sudan"
241 | },
242 | "_id": "ByYeSRH6P"
243 | },
244 | {
245 | "general": {
246 | "firstName": "Nicolette",
247 | "lastName": "Rogahn",
248 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/oskarlevinson/128.jpg"
249 | },
250 | "job": {
251 | "company": "Zemlak - Larkin",
252 | "title": "Internal Group Agent"
253 | },
254 | "contact": {
255 | "email": "Rowena.Kemmer93@yahoo.com",
256 | "phone": "579-666-9685"
257 | },
258 | "address": {
259 | "street": "6622 Kaitlin Drive",
260 | "city": "New Israel",
261 | "zipCode": "07116",
262 | "country": "Turkmenistan"
263 | },
264 | "_id": "Hk9xS0rTv"
265 | },
266 | {
267 | "general": {
268 | "firstName": "Aidan",
269 | "lastName": "Stracke",
270 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/falconerie/128.jpg"
271 | },
272 | "job": {
273 | "company": "Denesik, Dicki and Schmitt",
274 | "title": "National Accounts Officer"
275 | },
276 | "contact": {
277 | "email": "Chelsey.Powlowski38@hotmail.com",
278 | "phone": "689.859.2512 x81508"
279 | },
280 | "address": {
281 | "street": "498 King Track",
282 | "city": "Toy fort",
283 | "zipCode": "07905-5925",
284 | "country": "Latvia"
285 | },
286 | "_id": "B1ixSRrpD"
287 | },
288 | {
289 | "general": {
290 | "firstName": "Tristin",
291 | "lastName": "Eichmann",
292 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/johnriordan/128.jpg"
293 | },
294 | "job": {
295 | "company": "Marvin, Pfannerstill and Braun",
296 | "title": "Central Communications Consultant"
297 | },
298 | "contact": {
299 | "email": "Marisa_Miller54@yahoo.com",
300 | "phone": "335-788-4534"
301 | },
302 | "address": {
303 | "street": "2686 Ebert Parks",
304 | "city": "West Lexus",
305 | "zipCode": "70293-4149",
306 | "country": "Brazil"
307 | },
308 | "_id": "rk3gSCrpw"
309 | },
310 | {
311 | "general": {
312 | "firstName": "Malika",
313 | "lastName": "Feeney",
314 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/dnirmal/128.jpg"
315 | },
316 | "job": {
317 | "company": "Pfannerstill - Christiansen",
318 | "title": "Customer Assurance Designer"
319 | },
320 | "contact": {
321 | "email": "Stone15@yahoo.com",
322 | "phone": "578-268-2680 x495"
323 | },
324 | "address": {
325 | "street": "415 Homenick Lodge",
326 | "city": "North Nelson borough",
327 | "zipCode": "05142",
328 | "country": "Netherlands"
329 | },
330 | "_id": "HkTeH0raD"
331 | },
332 | {
333 | "general": {
334 | "firstName": "Ross",
335 | "lastName": "Dickens",
336 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/kuldarkalvik/128.jpg"
337 | },
338 | "job": {
339 | "company": "Champlin, Leannon and O'Connell",
340 | "title": "Legacy Marketing Coordinator"
341 | },
342 | "contact": {
343 | "email": "Scottie.Swift@hotmail.com",
344 | "phone": "165-084-3752 x336"
345 | },
346 | "address": {
347 | "street": "98494 Clemens Oval",
348 | "city": "Heller view",
349 | "zipCode": "58090",
350 | "country": "Mozambique"
351 | },
352 | "_id": "By0gr0HTv"
353 | },
354 | {
355 | "general": {
356 | "firstName": "Granville",
357 | "lastName": "Larson",
358 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/gregkilian/128.jpg"
359 | },
360 | "job": {
361 | "company": "Metz - Bashirian",
362 | "title": "Senior Brand Associate"
363 | },
364 | "contact": {
365 | "email": "Judd6@hotmail.com",
366 | "phone": "1-817-435-1136 x8014"
367 | },
368 | "address": {
369 | "street": "357 Jeffrey Avenue",
370 | "city": "Hand side",
371 | "zipCode": "89647-5238",
372 | "country": "Morocco"
373 | },
374 | "_id": "B11gxSRHTP"
375 | },
376 | {
377 | "general": {
378 | "firstName": "Donnie",
379 | "lastName": "Macejkovic",
380 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/guiiipontes/128.jpg"
381 | },
382 | "job": {
383 | "company": "Schmeler - Romaguera",
384 | "title": "Senior Functionality Facilitator"
385 | },
386 | "contact": {
387 | "email": "Camryn_Gerhold@hotmail.com",
388 | "phone": "(186) 005-2043"
389 | },
390 | "address": {
391 | "street": "76139 Hayes Plaza",
392 | "city": "Emard stad",
393 | "zipCode": "45180",
394 | "country": "Western Sahara"
395 | },
396 | "_id": "ryxelBRHav"
397 | },
398 | {
399 | "general": {
400 | "firstName": "Estell",
401 | "lastName": "Baumbach",
402 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/janpalounek/128.jpg"
403 | },
404 | "job": {
405 | "company": "Kuphal - Renner",
406 | "title": "Principal Mobility Associate"
407 | },
408 | "contact": {
409 | "email": "Cassie_Brekke@yahoo.com",
410 | "phone": "1-354-996-2400 x08787"
411 | },
412 | "address": {
413 | "street": "64955 Ottilie Port",
414 | "city": "Bartell mouth",
415 | "zipCode": "62822-8781",
416 | "country": "Belgium"
417 | },
418 | "_id": "SkZxgB0Hpv"
419 | },
420 | {
421 | "general": {
422 | "firstName": "Amelie",
423 | "lastName": "Bradtke",
424 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/vanchesz/128.jpg"
425 | },
426 | "job": {
427 | "company": "Swift LLC",
428 | "title": "Dynamic Program Representative"
429 | },
430 | "contact": {
431 | "email": "Prudence.Hane49@yahoo.com",
432 | "phone": "699-872-8424"
433 | },
434 | "address": {
435 | "street": "198 White Isle",
436 | "city": "Breitenberg land",
437 | "zipCode": "02161",
438 | "country": "Montserrat"
439 | },
440 | "_id": "H1zelS0Hpw"
441 | },
442 | {
443 | "general": {
444 | "firstName": "Elmer",
445 | "lastName": "D'Amore",
446 | "avatar": "https://s3.amazonaws.com/uifaces/faces/twitter/mfacchinello/128.jpg"
447 | },
448 | "job": {
449 | "company": "Schaden Group",
450 | "title": "Regional Brand Strategist"
451 | },
452 | "contact": {
453 | "email": "Margarett57@gmail.com",
454 | "phone": "1-236-341-6098 x2838"
455 | },
456 | "address": {
457 | "street": "26952 Welch Course",
458 | "city": "Lake Carmella land",
459 | "zipCode": "85577-5136",
460 | "country": "Israel"
461 | },
462 | "_id": "S1mlxHRraD"
463 | }
464 | ]
--------------------------------------------------------------------------------