Abstrct
In the past year, we decided to create out new sister app in React.
Sooner than later, we met on of the most famous issues with React's synchronous rendering:
render component that relies on an asynchronous data.
When you come from the ClojureScript+cljs/Om, you won't even think this need will raise any issue.
See, Clojure has its amazing channels, which makes asynchronous calls very easy.
Furthermore, cljs/Om lets you write you views in the code because, well, Clojure is Lisp, and everything is data.
To tackle this asynchronous issue, we did what everyone does;
we used compoenentDidMount:
1class App extends React.Component {
2 //...
3 componentDidMount() {
4 fetch("https://jsonplaceholder.typicode.com/todos/1")
5 .then((response) => response.json())
6 .then((json) => this.setState({ data: JSON.stringify(json) }));
7 }
8 //...
9}
10
This is not a good solution.
Problem in every store
In real app there are a lot of asynchronous calls.
Our application, is a medical application.
As such, the app needs to maintain and contain a lot of data, that needs to be loaded with the page.
Given that we came from a data-oriented language, Stores are one of the first concept I looked to implement, and after some investigations, arguments and a lot of asking around, I have managed to convince the team to use MobX.
Good developers - Good practice
MobX has a great utils library, including
.
Though fromPromise is not a new idea, and can be found in other places,
it's still a great solution to help your app be more reactive.
What fromPromise do generally, is to take a promise call, say fetch, and return a new promise, with state, and value.
Wait, what?
Here's where the magic comes in:
We return the promise into an observable, meaning it will return again on state change.
If you are familiar with channels, this will be very intuitive for you.
We were glad to find it (thank you Benjamin Gruenbaum), and I immediately started to brainstrom with myself on ways to use is on a system-wide scale.
After some thought I wrote the following function:
1function viewByState(promise, props, elementFn) {
2 switch (promise.state) {
3 case "pending":
4 return <div>pending</div>;
5 case "rejected":
6 return <div>Error {promise.value.toString()}</div>;
7 case "fulfilled":
8 return elementFn(promise.value);
9 default:
10 return "Nothing to see here";
11 }
12}
13
And used it in the component
1async function getData() {
2 const data = await fetch("https://jsonplaceholder.typicode.com/todos/1");
3 return data.json();
4}
5
6@observer
7class App extends React.Component {
8 @observable promised = fromPromise(getData());
9 view = (data) => <div>{JSON.stringify(data)}</div>;
10 render() {
11 return viewByState(this.promised, this.props, this.view);
12 }
13}
14
Cool!
Yet, this is not enough.
We now have a new problem!
Actually, two.
Problem 1 - disconnected
When you return the promise results, you loose the connection to any other store.
this can be easily fixed by returning the props and the result.
Problem 2 - Declare Yourself
This method is not declarative.
and cannot be nested easily.
Final Cut
Enter the declarative AsyncRender:
1function viewByState(promise, props, elementFn) {
2 switch (promise.state) {
3 case "pending":
4 return <div>pending</div>;
5 case "rejected":
6 return <div>Error {promise.value.toString()}</div>;
7 case "fulfilled":
8 return elementFn(promise.value);
9 default:
10 return "Nothing to see here";
11 }
12}
13async function getData() {
14 const data = await fetch("https://jsonplaceholder.typicode.com/todos/1");
15 return data.json();
16}
17
18@observer
19class App extends React.Component {
20 @observable promised = fromPromise(getData());
21 view = (data) => <div>{JSON.stringify(data)}</div>;
22 render() {
23 const promised = this.promised;
24 return (
25 <AsyncRender promise={promised}>
26 <div>{JSON.stringify(promised.value)}</div>
27 </AsyncRender>
28 );
29 }
30}
31
32@observer
33class AsyncRender extends React.Component {
34 render() {
35 const promise = this.props.promise;
36 switch (promise.state) {
37 case "pending":
38 return <div>pending</div>;
39 case "rejected":
40 return <div>Error {promise.value.toString()}</div>;
41 case "fulfilled":
42 return this.props.children;
43 default:
44 return "Nothing to see here";
45 }
46 }
47}
48
Async? Sync?
Let's take a look at this implementation's power, shall we?
Let assume we have one page.
The page has a list, with a nested data in each
Every is async, so does the list itself
Inside some there is async data.
easy!.
1function viewByState(promise, elementFn) {
2 switch (promise.state) {
3 case "pending":
4 return <div>pending</div>;
5 case "rejected":
6 return <div>Error {promise.value.toString()}</div>;
7 case "fulfilled":
8 return elementFn(promise.value);
9 default:
10 return "Nothing to see here";
11 }
12}
13async function getData(id) {
14 const data = await fetch(
15 `https://jsonplaceholder.typicode.com/posts/${id}`
16 );
17 return data.json();
18}
19async function postComments(id) {
20 const data = await fetch(
21 `https://jsonplaceholder.typicode.com/posts/${id}/comments`
22 );
23 return data.json();
24}
25async function getPost() {
26 const data = await fetch(
27 "https://jsonplaceholder.typicode.com/posts?userId=1"
28 );
29 return data.json();
30}
31function commentCount(comments) {
32 if (comments && comments.value) {
33 return comments.value.length;
34 }
35}
36
37@observer
38class App extends React.Component {
39 @observable promised = fromPromise(getPost());
40 @observable
41 listElements = [
42 {
43 post: fromPromise(getData(1)),
44 comments: fromPromise(postComments(1)),
45 },
46 {
47 post: fromPromise(getData(2)),
48 comments: fromPromise(postComments(2)),
49 },
50 {
51 posts: fromPromise(getData(3)),
52 comments: fromPromise(postComments(3)),
53 },
54 {
55 post: fromPromise(getData(4)),
56 comments: fromPromise(postComments(4)),
57 },
58 {
59 post: fromPromise(getData(5)),
60 comments: fromPromise(postComments(5)),
61 },
62 ];
63 @computed
64 get postCount() {
65 if (this.promised && this.promised.value)
66 return this.promised.value.length;
67 }
68 render() {
69 const promised = this.promised;
70 return (
71 <AsyncRender promise={promised}>
72 <b>{this.postCount} Posts found</b>
73 {this.listElements.map((item, index) => {
74 return (
75 <AsyncRender promise={item.post}>
76 <div>{JSON.stringify(item.post.value)}</div>
77 {viewByState(item.comments, () => (
78 <div>
79 Found {item.comments.value.length} comments{" "}
80 </div>
81 ))}
82 </AsyncRender>
83 );
84 })}
85 </AsyncRender>
86 );
87 }
88}
89
90@observer
91class AsyncRender extends React.Component {
92 render() {
93 const promise = this.props.promise;
94 switch (promise.state) {
95 case "pending":
96 return <div>pending</div>;
97 case "rejected":
98 return <div>Error {promise.value.toString()}</div>;
99 case "fulfilled":
100 return this.props.children;
101 default:
102 return "Nothing to see here";
103 }
104 }
105}
106
I know that for a lot of you guys, the smart ones, this is all but a new thing.
In my case, coming to this solution was a great journey of re-wiring my mind to think in terms and design-patterns not usually combined with JavaScript for most of us.
This gave us the power to load our data at will, and to rethink on our Stores structures and realize they were too coupled to the UI.
This solution gave us the possibility to write the MobX store without thinking about the UI flow, as from now on, the UI loaded what it needs at will.
Furthermore, this idea gave us easy route to a consistent UI, meaning AsyncRender can show unified error, loaded, and messages to the user.
Last, but not least, is the developing process.
We now can see visually which async call failed, as the corresponding UI will show an error, without breaking other UI, and will prevent rendering of elements the rely on the failed data retrieval .
What are your thought? :)