Skip to content
This repository has been archived by the owner on Sep 10, 2022. It is now read-only.

Merge rx-recompose into main project #196

Merged
merged 18 commits into from
Jun 16, 2016
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"node": true
},
"globals": {
"sinon": true
"sinon": true,
"Observable": true
},
"rules": {
"semi": [2, "never"],
Expand Down
102 changes: 102 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const PureComponent = pure(BaseComponent)
+ [`componentFromProp()`](#componentfromprop)
+ [`nest()`](#nest)
+ [`hoistStatics()`](#hoiststatics)
* [Observable utilities](#observable-utilities)
+ [`componentFromStream()`](#componentfromstream)
+ [`mapPropsStream()`](#mappropsstream)
+ [`createEventHandler()`](#createEventHandler)

## Higher-order components

Expand Down Expand Up @@ -626,3 +630,101 @@ hoistStatics(hoc: HigherOrderComponent): HigherOrderComponent
```

Augments a higher-order component so that when used, it copies static properties from the base component to the new component. This is helpful when using Recompose with libraries like Relay.

## Observable utilities

It turns out that much of the React Component API can be expressed in terms of observables:

- Instead of `setState()`, combine multiple streams together.
- Instead of `getInitialState()`, use `startWith()` or `concat()`.
- Instead of `shouldComponentUpdate()`, use `distinctUntilChanged()`, `debounce()`, etc.

Other benefits include:

- No distinction between state and props – everything is an stream.
- No need to worry about unsubscribing from event listeners. Subscriptions are handled for you.
- Sideways data loading is trivial – just combine the props stream with an external stream.
- Access to an ecosystem of observable libraries, such as RxJS.

### `componentFromStream()`

```js
componentFromStream(
propsToReactNode: (props$: Observable<object>) => Observable<ReactNode>
): ReactComponent
```

Creates a React component by mapping an observable stream of props to a stream of React nodes (vdom).

You can think of `propsToReactNode` as a function `f` such that

```js
const vdom$ = f(props$)
```

where `props$` is a stream of props and `vdom$` is a stream of React nodes. This formulation similar to the popular notion of React views as a function, often communicated as

```
v = f(d)
```

```js
const Counter = componentFromStream(props$ => {
const { handler: increment, stream: increment$ } = createEventHandler()
const { handler: decrement, stream: decrement$ } = createEventHandler()
const count$ = Observable.merge(
increment$.map(() => 1),
decrement$.map(() => -1)
)
.startWith(0)
.scan((count, n) => count + n, 0)

return props$.combineLatest(
count$,
(props, count) =>
<div {...props}>
Count: {count}
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
})
```

### `mapPropsStream()`

```js
mapPropsStream(
ownerPropsToChildProps: (props$: Observable<object>) => Observable<object>,
BaseComponent: ReactElementType
): ReactComponent
```

A higher-order component version of `componentFromStream()` — accepts a function that maps an observable stream of owner props to a stream of child props, rather than directly to a stream of React nodes. The child props are then passed to a base component.

You may want to use this version to interoperate with other Recompose higher-order component helpers.

```js
const enhance = mapPropsStream(props$ => {
const timeElapsed$ = Observable.interval(1000).pluck('value')
props$.combineLatest(timeElapsed$, (props, timeElapsed) => ({
...props,
timeElapsed
}))
})

const Timer = enhance(({ timeElapsed }) =>
<div>Time elapsed: {timeElapsed}</div>
)
```

### `createEventHandler()`

```js
createEventHandler<T>(): {
handler: (value: T) => void
stream: Observable<T>,
}
```

Returns an object with properties `handler` and `stream`. `stream` is an observable sequence, and `handler` is a function that pushes new values onto the sequence. (This is akin to mailboxes in Elm.) Useful for creating event handlers like `onClick`.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"react-dom": "^15.0.0",
"readline-sync": "^1.2.21",
"rimraf": "^2.4.3",
"rxjs": "^5.0.0-beta.8",
"shelljs": "^0.6.0",
"sinon": "^1.17.1",
"webpack": "^1.12.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
import test from 'ava'
import { createEventHandler } from '../'

test('createEventHandler creates a subject that broadcasts new values when called as a function', t => {
test('createEventHandler creates an event handler and a corresponding stream', t => {
const result = []
const { stream, handler } = createEventHandler()
const subscription = stream.subscribe(v => result.push(v))
const subscription = stream.subscribe({ next: v => result.push(v) })

handler(1)
handler(2)
handler(3)

subscription.dispose()
subscription.unsubscribe()
t.deepEqual(result, [1, 2, 3])
})

test('handles multiple subscribers', t => {
const result1 = []
const result2 = []
const { handler, stream } = createEventHandler()
const subscription1 = stream.subscribe(v => result1.push(v))
const subscription2 = stream.subscribe(v => result2.push(v))
const subscription1 = stream.subscribe({ next: v => result1.push(v) })
const subscription2 = stream.subscribe({ next: v => result2.push(v) })

handler(1)
handler(2)
handler(3)

subscription1.dispose()
subscription2.dispose()
subscription1.unsubscribe()
subscription2.unsubscribe()

t.deepEqual(result1, [1, 2, 3])
t.deepEqual(result2, [1, 2, 3])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import test from 'ava'
import React from 'react'
import { Observable, Subject } from 'rx'
import { withState, compose, branch } from 'recompose'
import { mapPropsStream, createEventHandler } from '../'
import { combineLatest } from 'rxjs/operator/combineLatest'
import { startWith } from 'rxjs/operator/startWith'
import { scan } from 'rxjs/operator/scan'
import { _do } from 'rxjs/operator/do'
import { map } from 'rxjs/operator/map'
import { Observable } from 'rxjs'
import {
withState,
compose,
branch,
mapPropsStream,
createEventHandler
} from '../'
import configureObservable from '../configureObservable'
import { mount, shallow } from 'enzyme'

// Convert plain observables to RxJS observables
configureObservable(observable => Observable.from(observable))

const identity = t => t

test('mapPropsStream maps a stream of owner props to a stream of child props', t => {
const SmartButton = mapPropsStream(props$ => {
const { handler: onClick, stream: increment$ } = createEventHandler()
const count$ = increment$
.startWith(0)
.scan(total => total + 1)
::startWith(0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bind operator is still at stage 0 so syntax may change in future and there are no guarantees it will be included in spec

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it's a test, so oh well for now :D

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I'm importing each helper separately is to ensure as much as possible that we're not depending on any RxJS-specific functionality.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, I understand that

::scan(total => total + 1)

return Observable.combineLatest(props$, count$, (props, count) => ({
return props$::combineLatest(count$, (props, count) => ({
...props,
onClick,
count
Expand All @@ -37,10 +51,10 @@ test('mapPropsStream works on initial render', t => {
const SmartButton = mapPropsStream(props$ => {
const { handler: onClick, stream: increment$ } = createEventHandler()
const count$ = increment$
.startWith(0)
.scan(total => total + 1)
::startWith(0)
::scan(total => total + 1)

return Observable.combineLatest(props$, count$, (props, count) => ({
return props$::combineLatest(count$, (props, count) => ({
...props,
onClick,
count
Expand All @@ -57,10 +71,10 @@ test('mapPropsStream receives prop updates', t => {
const SmartButton = mapPropsStream(props$ => {
const { handler: onClick, stream: increment$ } = createEventHandler()
const count$ = increment$
.startWith(0)
.scan(total => total + 1)
::startWith(0)
::scan(total => total + 1)

return Observable.combineLatest(props$, count$, (props, count) => ({
return props$::combineLatest(count$, (props, count) => ({
...props,
onClick,
count
Expand All @@ -87,8 +101,8 @@ test('mapPropsStream unsubscribes before unmounting', t => {
props => props.observe,
mapPropsStream(() =>
increment$
.do(() => { count += 1 })
.map(() => ({}))
::_do(() => { count += 1 })
::map(() => ({}))
),
identity
)
Expand All @@ -109,20 +123,20 @@ test('mapPropsStream unsubscribes before unmounting', t => {
})

test('mapPropsStream renders null until stream of props emits value', t => {
const props$ = new Subject()
const { stream: props$, handler: setProps } = createEventHandler()
const Container = mapPropsStream(() => props$)('div')
const wrapper = mount(<Container />)

t.false(wrapper.some('div'))
props$.onNext({ foo: 'bar' })
setProps({ foo: 'bar' })
t.is(wrapper.find('div').prop('foo'), 'bar')
})


test('handler multiple observers of props stream', t => {
const Container = mapPropsStream(props$ =>
// Adds three observers to props stream
props$.combineLatest(
props$::combineLatest(
props$, props$,
props1 => props1
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { Component } from 'react'
import { Observable } from 'rx'
import { createChangeEmitter } from 'change-emitter'
import $$observable from 'symbol-observable'
import { getTransform } from './configureObservable'

const createComponent = propsToVdom =>
class RxComponent extends Component {
const componentFromStream = propsToVdom =>
class ComponentFromStream extends Component {
state = {};

propsEmitter = createChangeEmitter();

// Stream of props
props$ = Observable.create(observer =>
this.propsEmitter.listen(props => observer.onNext(props))
);
props$ = getTransform()({
subscribe: observer => {
const unsubscribe = this.propsEmitter.listen(
props => observer.next(props)
)
return { unsubscribe }
},
[$$observable]() {
return this
}
});

// Stream of vdom
vdom$ = propsToVdom(this.props$);
Expand All @@ -23,17 +32,16 @@ const createComponent = propsToVdom =>

componentWillMount() {
// Subscribe to child prop changes so we know when to re-render
this.subscription = this.vdom$.subscribe(
vdom => {
this.subscription = this.vdom$.subscribe({
next: vdom => {
this.didReceiveVdom = true
if (!this.componentHasMounted) {
this.state = { vdom }
Copy link
Contributor

@istarkov istarkov Jun 13, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why check on hasMounted? (It was a bug in react, but it solved)
Also looks like this.state = {} could be changed on this.setState as this method does not called from contructor

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was a bug in react, but it solved

Link?

Also looks like this.state = {} could be changed on this.setState as this method does not called from contructor

You're right. This is left over from when it used to subscribe inside the constructor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link ?

One of:
facebook/react#5719
fixed in 15.1

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check is to prevent calling setState if the component has already unmounted. That issue seems unrelated? Could be wrong.

return
} else {
this.setState({ vdom })
}
this.setState({ vdom })
}
)

})
this.propsEmitter.emit(this.props)
}

Expand All @@ -52,7 +60,7 @@ const createComponent = propsToVdom =>

componentWillUnmount() {
// Clean-up subscription before un-mounting
this.subscription.dispose()
this.subscription.unsubscribe()
}

render() {
Expand All @@ -61,4 +69,4 @@ const createComponent = propsToVdom =>
}
}

export default createComponent
export default componentFromStream
10 changes: 10 additions & 0 deletions src/packages/recompose/configureObservable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Default transform is identity function
let transform = t => t

export const getTransform = () => transform

const configureObservable = newTransform => {
transform = newTransform
}

export default configureObservable
23 changes: 23 additions & 0 deletions src/packages/recompose/createEventHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import $$observable from 'symbol-observable'
import { createChangeEmitter } from 'change-emitter'
import { getTransform } from './configureObservable'

const createEventHandler = () => {
const emitter = createChangeEmitter()
const transform = getTransform()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@acdlite Maybe allow pass to configureObservable subject-like anything? And use change-emitter if only passed Observable dont has method next?

Copy link
Contributor

@chicoxyzzy chicoxyzzy Jun 12, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@typeetfunc Subject is Rx-related thing. There are no subjects in most. There is https://github.com/TylorS/most-subject but @TylorS once said in most.js gitter chat that it's more like motorcycle/cycle-related thing.
Discussion about Subjects in most cujojs/most#164

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chicoxyzzy Thanks for links. Also observer dont has interop point like symbol-observable - tc39/proposal-observable#89. Perhaps my proposal was a bit hasty :)

const stream = transform({
subscribe(observer) {
const unsubscribe = emitter.listen(value => observer.next(value))
return { unsubscribe }
},
[$$observable]() {
return this
}
})
return {
handler: emitter.emit,
stream
}
}

export default createEventHandler
5 changes: 5 additions & 0 deletions src/packages/recompose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ export createSink from './createSink'
export componentFromProp from './componentFromProp'
export nest from './nest'
export hoistStatics from './hoistStatics'

// Observable helpers
export componentFromStream from './componentFromStream'
export mapPropsStream from './mapPropsStream'
export createEventHandler from './createEventHandler'
Loading