Closed leebenson closed 7 years ago
It's an interesting problem you have here.
I'm not sure I understood it all, so help me out if I wander off...
But, If I do understand correctly, you are using some kind of "state machine" on the server and the client which you need to subscribe to. I don't know if you insist on Redux but If you want to have a complete decoupled control over that stream, I don't thing Redux is the way to go. Because, it's "tightly" connected to components, updating all of them on any change.
What I would do in Recycle is a Redux-like driver which produces state stream based on a component (or any other) actions.
Your components can listen for a custom made stream myCustomState$
and update on any change:
function ClickCounter () {
return {
actions (sources) {
return [
sources.select('button')
.on('click')
.mapTo({ type: 'buttonClicked' })
]
}
reducers (sources) {
return [
sources.myCustomState$
.reducer(function (currentComponentState, stateFromDriver) {
// received state from a independent state machine
// you can also use currentComponentState for any additional component logic as well
return stateFromDriver
})
]
},
view (props, state) {
return (
<div>
<span>Times clicked: {state.timesClicked}</span>
<button>Click me</button>
</div>
)
}
}
}
Implementation of the state machine, which is providing myCustomState$
to your components:
function stateStreamDriver (recycle, Rx) {
const state$ = new Rx.Subject()
// keeping track of the last state
// it can probably be written nicer, using only streams (using .last() or similar)
// rather than local variable, but its 1:28am :)
let currentState
state$.subscribe(function (newState) {
currentState = newState
})
// "feeding" state$ by listening to dispatched component actions
recycle.on('action', function (component, action) {
// any logic for connecting your components with the reducers
if (component.get('container') === undefined) {
// optional filtering of components
// if you want to make sure only certain components are allowed to dispatch actions
// like containers in redux
return
}
let newState = someReducerLogic(currentState, action)
state$.next(newState)
})
// providing state$ to your components
recycle.on('componentInit', function (component) {
if (component.get('someProperty') === undefined) {
// optional filtering of components that don't require state$
// usefull if using multiple state machines
return
}
component.setSource('myCustomState$', state$)
// injecting a component initial state
component.replaceState(currentState)
})
// making state$ available to all recycle drivers
recycle.set('myCustomState$', state$)
// communication with other kind of drivers (if necessary)
recycle.get('someOtherStream$').subscribe(function (x, y) {
// if for example some kind of notification service on the backend
// should result in a state change
state$.next(fn(x,y))
})
}
The problem I have is signalling to my web server that the data stream is 'ready' before rendering the HTML. If I have an async stream, I want the server to 'wait' until it has a value before starting the React chain and throwing back to the initial markup.
I believe you can listen for any stream for something like this, for example on myCustomState$
created above.
... that I can subscribe to outside of the component chain?
A recycle driver is outside of the component chain and it can be used by other driver with: recycle.get('myCustomState$')
.
But it can also be created outside of recycle driver as well, and passed in a stateStreamDriver
driver as an argument. That really depends on your implementation.
Thanks @domagojk for your detailed response - I appreciate you taking the time to write it!
I think there are elements of your code examples that can be used in what I'm trying to do, but I'm still not fully sure of the implementation details as it pertains to the server.
Let's say I have a very simple component like this:
const App = props => (
<div>
Message: {props.message}
</div>
);
And I have a simple stream that emits values after, 500ms (to simulate a typical delay that may occur with an async call to, say, a database API):
Rx.Observable.of('hello', 'world').zip(
Rx.Observable.interval(500),
a => a
);
What I'm trying to do is:
<App/>
component on the server with 'message: hello' as the output, and then unsubscribe and dump the HTML back to the clientReactDOM.renderToString
I'd ultimately like to use that pattern in the ReactNow starter kit I'm building, so users have a quick foundation for writing all of their async code against RxJS, universally, without having to write lots of logic to handle subscriptions or worry about memory leaks for long-running events that are emitted after the response has been sent back to the browser.
With RxJS, that ought to be trivial because of its 'push' model - simply appending .take(1)
to a stream that might otherwise emit multiple values is enough to guarantee that any subscriptions won't try to mutate this.state
on a component after that value has been received.
My main problem is figuring out how to 'wait' until that first value for all streams has been received (so we can render HTML back to the screen with at least some useful first value, before the browser takes over), without calling renderToString
to kick-start that process.
It seems Recycle is perfect for that, because the lifecycle hooks and observes 'live' outside of the React chain completely. I'm just trying to wrap my head around the API and figure out exactly which pieces in your example I need to put in place to make that simple process above happen.
I'll write my answer as detailed as I can, so it can maybe be used for documentation or a working example.
I also understand that recycle API is not clear enough. That is my fault. I should have wrote more examples solving problems like this one.
But, let me jut say it's a trill to receive enquiries from people that, for a change, actually understood the concepts of reactive programming and are aware of its benefits :)
I will rewrite the task definition in my own words, so please modify the following description if I got it wrong:
Problem description Create an app using SSR while using async streams on the client and on the server. The only difference between server and client is that components on the server should update only after the state is calculated for the first time. (Source of the state is from a store similar to Redux but created as an observable stream).
My solution
First, I need to explain a small difference between Redux container component and containers in this app.
react-redux container uses React context API for subscribing to the store and automatically updates wrapped component if the store is changed.
In recycle, a subscription logic is not written in a redux connect
function but the component is updating its own state by reacting to source stream.
So, for this simple component:
const App = props => (
<div>
Message: {props.message}
</div>
);
we can define its container component:
const AppContainer = () => ({
reducers: (sources) => {
// listen for a custom created stream representing store state
// update component when new state is dispatched from the stream
return sources.store$
.reducer((oldState, newState) => newState)
},
view: (props, state) => (
<App message={state.message} />
)
});
sources.store$
is not available by default, but we need to create it.
We can do this by using a - driver.
Driver is a simple function with recycle
instance provided as a first argument.
You may combine the following with stateStreamDriver
from my last reply to fully implement something similar to redux:
function storeDriver (recycle, Rx) {
// usually you need to create a stream based on components action
// but for simplicity sake, store is presented as a stream
// which is dispatching string "hello" and then "world" after 500ms
const store$ = Rx.Observable.of('hello', 'world').zip(
Rx.Observable.interval(500),
a => a
)
recycle.on('componentInit', component => {
// feeding components with the state stream
component.setSource('store$', store$)
// after this, sources.store$ will be available in the component
})
return {
name: 'store',
store$
}
}
Rendering this app on the client side would look something like this:
React.render(<Recycle root={AppContainer} drivers={storeDriver} />, document.getElementById('root'))
But it's more complex for the server because its not a synchronous operation. Which is why we use "custom made" initialization of recycle:
import React from 'react'
import express from 'express'
import ReactDOMServer from 'react-dom/server'
import Rx from 'rxjs/Rx'
// dependencies for creating "custom made" recycle instance
import Recycle from 'recyclejs/recycle'
import streamAdapter from 'recyclejs/adapter/rxjs'
import reactDriver from 'recyclejs/drivers/react' // edit: corrected typo
// drivers and components
import storeDriver from './drivers/store'
import App from './containers/App'
const app = express()
app.get('/', function (req, res) {
const recycle = Recycle(streamAdapter(Rx))
recycle.use(reactDriver(React), storeDriver)
const AppReact = recycle.createComponent(App).get('ReactComponent')
// getDriver('store') is avaiable
// because storeDriver had returned an object: { name: 'store', store$: <stream> }
recycle.getDriver('store').store$.take(1)
.subscribe(
nextState => {
// console.log(nextState)
},
err => {
// console.error(err)
},
() => {
// stream has completed
// first event was fired, and components had updated
let html = ReactDOMServer.renderToString(AppReact)
res.send(html)
}
)
})
I hope this helps, but let me know if I didn't understand the concept correctly. Also, note that provided code has not been tested, so feel free to report any bugs.
Thanks @domagojk, that helps a LOT. I think there's enough between your two examples for me to figure out an implementation for the starter kit.
I'll write back here once I've wired it all up.
Thanks again!
I made a couple of changes to your snippet:
import reactDriver from './drivers/react'
changed to:
import reactDriver from 'recyclejs/drivers/react'; // <-- pulling from the lib, not locally
And:
recycle.use(reactDriver(React), storeDriver)
Changed to:
recycle.use(storeDriver, reactDriver(React)); // <-- swapped the drivers around, to make `store$` available
console.log
in the nextState
part of the subscription is successfully returning:
next -> hello
But... this part returned undefined:
const AppReact = recycle.createComponent(App).get('ReactComponent');
I tried .get('view')
from the API docs to get raw access to the underlying view function... but I'm not sure how to do the following:
props/state
into it the component so that the same subscription value already returned can be re-used without re-subscribing/re-kickstarting the processAny ideas?
@leebenson Yea, you are right, react driver should be from the lib..
state$
should be available regardless of a driver order, though.
But, I'm not sure why you got undefined on get('ReactComponent')
Is there a repository available so I can reproduce the bug?
Sure, grab with:
git clone https://github.com/leebenson/reactnow --depth 1 -b recyclejs --single-branch
Then run:
npm i && npm run build-server && node dist/server
It will spawn a server at http://localhost:4000. Curl it, and you'll see the error logged to the console.
There's a ton of code that won't be relevant in this repo, since you're installing all the webpack config, the browser entry point, etc. I've commented out things like React Router to avoid complicating the set-up at the moment.
The entry point is lib/kit/server.js
.
Let me know if you run into any issues. My goal is to get this working and put Recycle front and centre in the starter kit, and build a custom driver for it that handles streams in an isomorphic way.
(btw, SERVER === true
if you're running on the server, so this would be an easy way to build out the driver to know whether to add .take(1)
/ merge all streams into one that emits when first values are available on the full component chain.)
@leebenson I got this error with webpack while trying to build it:
Do you know what could be the issue?
Which version of Node are you running? Is it < 6.4?
I don't think externals are compiled, so it might be that default func parameters aren't available by default on your installation?
Yea, that was it :+1: I reproduced the bug... I'll let you how it goes
Cool, thanks for taking the time to look at it. Really appreciate it!
@leebenson No problem, by using it you are also helping me with recycle :)
I never tested recycle with react driver on node before, and there were some exceptions (like "object is not extensible" errors) which I didn't caught on a browser.
I've released recycle to 1.0.1 to fix that issue.
Issue with 'GetReactComponent'
was due to bad type checking on my part...
It works with recycle.use([storeDriver, reactDriver(React)])
(must be defined as an array)
Unfortunately, this is not enough to make it work.
The reason is that React driver is depended on componentDidMount
and componentDidUnmount
hooks which are never triggered on the server (I'm not 100% sure that is the case, but that is my current conclusion).
But, this can all be done by creating similar "ReactServer" (or some other name) driver which will not depend on that.
I will definitely look into that over the next few days, and publish the new driver
Great, thanks @domagojk.
The reason is that React driver is depended on componentDidMount and componentDidUnmount hooks which are never triggered on the server (I'm not 100% sure that is the case, but that is my current conclusion).
Yeah, componentWillMount()
is the only event that fires on the server.
I will definitely look into that over the next few days, and publish the new driver
Beautiful, thanks. Looking forward to trying it out. I haven't seen SSR for RxJS done properly anywhere, so I think Recycle will have a real advantage over anything else I've seen.
@leebenson updating state for SSR components is now working :)
First, you need to update Recycle (as of version 1.1.1 react stateless functions are supported and recycle.use
works as initially defined)
Fortunately, creating new "server" driver was not necessary. If I understood correctly, updating React component on the server by invoking setState is not possible with React. However that doesn't mean Recycle can't define the state for the component it wraps.
I've just added component.replaceState
statement in existing driver.
store.js
:
export default function storeDriver(recycle, Rx) {
// usually you need to create a stream based on components action
// but for simplicity sake, store is presented as a stream
// which is dispatching string "hello" and then "world" after 500ms
const store$ = Rx.Observable.of(
{message: 'hello'},
{message: 'world'}
).zip(
Rx.Observable.interval(500),
a => a,
);
recycle.on('componentInit', component => {
// feeding components with the state stream
component.setSource('store$', store$);
// after this, sources.store$ will be available in the component
});
recycle.on('sourcesReady', component => {
component.getStateStream().subscribe(({ state }) => {
component.replaceState(state)
})
})
return {
name: 'store',
store$,
};
}
When defined like this, if in "server.js" you use take(1)
, dumped html will be "Message: hello"
for take(2)
, dumped html is "Message: world".
Let me know if you encounter any other problem. I would love to see a complete working example which would serve as another nice case for using Recycle.
Awesome, thanks! I'll play with this tonight, and let you know how I get on.
FWIW, setState()
works on the server the first time (or more accurately, whilst still within the constructor or componentWillMount
block), but once rendered with renderToString
, no longer allows updates and will log a warning to the the console.
So you should be able to use setState()
without any issues, since you're only only rendering after the stream completion emits.
You're right, it's possible to setState on componentWillMount, but that event is triggered after renderToString
.
At that point, it's already too late. Recycle can't subscribe to a state stream because, in this case, state$ is already finished (we are rendering to string when its completed).
When used in driver though, you can change the state (which is actually equivalent of changing React's initialState) before it's initially rendered.
Also, I've refactored your example a little bit and added PR here https://github.com/leebenson/reactnow/pull/1
Makes sense. Awesome. Thanks @domagojk!
Awesome work on adding to the reactnow starter kit @domagojk. Thanks a lot for your help.
The next phase for me is figuring out:
How to make it work with routing. I'm using React Router (v3 currently, so I can define routes per a config object and get a known 'Root' component). I guess that's the component that will need feeding into recycle.createComponent
, rather than just a static 'App'.
How to remove the current store$
sample/boilerplate, and instead decorating components with actions
.
How to write some stream transforms that merge all of the dynamic actions together in the 'serverStore' so that we can take(1)
when they're all ready, and not just the first one.
You've been a huge help so far and I don't want to ask any more of your time... but if you did fancy helping out on the above, please be my guest. This is all new to me, so we'd probably get the starter kit off the ground faster with your help than me trying to figure it out. I could then spend more time working on the docs and other pieces of the stack. But your call, obviously.
Thanks!
@leebenson I will definitely help you out. I'm in the process of repositioning recycle a little bit as a tool which should be most useful if used in complex scenarios, in React and other (probably node.js) apps. So your example is a good use case which I can also refer to. I'm done with explaining click counter examples :)
That said, this week is a bit crazy for me, so I'll answer your questions soon - probably at the end of the week
you can also follow and help me out with the development of version 2: https://github.com/recyclejs/recycle/tree/v2.0
ok, awesome.
Another thing I thought of (which you may have covered in the lib) is what happens if the 'root' component is just a plain React comp that isn't decorated with Recycle... can it handle sub-components that are decorated with the view(props, state)
syntax deeper in the chain?
You can convert Recycle into React component and use it like any other. Like this TodoMVC is used in react-router https://github.com/recyclejs/recycle/blob/master/examples/TodoMVC/src/index.js
API of this may change a bit in v2
Is that what you meant?
IMO, in v2 this is much more cleaner:
const AppReact = recycle.createReactComponent(App)
App
can be plain react component containing recycle and/or components or a recycle component
What I mean is this:
Let's say we have three components:
A
<-- plain ReactB
<-- Recycle'dC
<-- Recycle'dAnd the components look like this:
const A = () => (
<B />
);
class B extends React.PureComponent {
reducers() { /* reducers here */ }
view(props, state) {
return (
<C />
);
}
}
class C extends React.PureComponent {
reducers() { /* reducers here */ }
view(props, state) {
return (
<h1>{state.message}</h1>
);
}
}
And we instantiate with:
const AppReact = recycle.createReactComponent(A)
;
Will <B>
and <C>
render properly, even though A
is a plain component that defines no Recycle actions/reducers of its own?
And based on one of my earlier bullet points, can we combine the actions from <B>
and <C>
in the server driver, to merge and take the first result from the combined stream just as easily, even though <A>
is plain?
This is more a question of usability for my starter kit, since a common use case will be only decorating the components that have streams, and leaving everything else as plain React.
Will \ and \
render properly, even though \ is a plain component that defines no Recycle actions/reducers of its own?
Yes. It should render properly. Because AppReact is "recycle'd" using recycle.createReactComponent
And based on one of my earlier bullet points, can we combine the actions from B and C in the server driver, to merge and take the first result from the combined stream just as easily, even though A is plain?
Yes using hooks in driver:
function(recycle, Rx) {
recycle.on('action', function(component, action) {
// use action to create stream
}
}
or in v2 there is also an option
recycle.action$
.map([component, action] => something)
Ok, beautiful. Apologies for the rudimentary questions... just thinking of things that might trip me up once I start digging in to the lib. And sorry hijacking your issue board with these Qs 😄
😄 no problem! you can also use gitter chat if you like: https://gitter.im/recyclejs
But we can continue here as well, I'll close the issue though
Neat library.
I'm wondering if there's an obvious/preferred pattern for using it alongside SSR for universal components?
I have a comprehensive webpack build that defines
CLIENT
andSERVER
constants to indicate which platform React is being rendered on. This makes it trivial to handle RxJS streams with a simple if/else statement to tack on.take(1)
to prevent over-subscribing on the server and attempting tosetState()
on a component afterReactDOM.renderToString()
has already been called.So, far so good.
The problem I have is signalling to my web server that the data stream is 'ready' before rendering the HTML. If I have an async stream, I want the server to 'wait' until it has a value before starting the React chain and throwing back to the initial markup. On the client, it can
setState
as many times as it wants.Is there anything built into the Recycle API that would allow me to merge ALL component reducers into a single stream that's emitted when every individual reducer has received its first value, that I can subscribe to outside of the component chain?
That way, I could simply await on a merged stream Promise for that to occur before calling
renderToString
and be assured that the React chain being built is going to subscribe to a value that's 'hot' and is available before attempting to generate markup.I started spinning up my own library to handle this, but creating a new 'context' for each new request and then figuring out the best way to decorate components and get access to the original streams within the same context is a rabbit hole I'd rather not get lost down if this lib can already do all/most of it.
Thanks in advance for any suggestions!