0.1.3 • Published 4 years ago

react-futures v0.1.3

Weekly downloads
2
License
MIT
Repository
github
Last release
4 years ago

React Futures

Manipulate asynchronous data synchronously

Table of contents

Install

This requires you to have React's experimental build installed to enable Concurrent Mode:

#yarn
yarn add react@experimental react-dom@experimental

#npm
npm install react@experimental react-dom@experimental

To install this library:

#npm
npm i react-futures

#yarn
yarn add react-futures

Explainer

React Futures is a collection of types that allow manipulation of asynchronous data in a synchronous manner. This happens by deferring the actual data processing until after the promise resolves and suspending only when necessary. This means you don't have to worry about waiting for your fetches, just perform your usual array and object operations on the React Futures object!

For example:

import { futureArray } from 'react-futures';

const FutureBlogs = futureArray( user => fetch(`/blogs?user=${user}`).then(res => res.json()))

const Blogs = ({ user }) => {
  const blogs =  new FutureBlogs(user)// fetch here
                  .filter( blog => blog.tags.includes('sports')) // lazy
                  .slice(0,10) // lazy

  const featured = blogs[0] //suspend!

  return ...
}

React Futures follows the "wait-by-necessity" principle; it defers suspension until the code examines the content of the data. Only after suspense resolves are operations like map, slice, and filter applied. This simplifies data construction by hiding the data fetching logic.

When the requirements for data fetching increases, the benefits of React Futures become clearer. With React Futures the construction and consumption of the data is decoupled from the fetching of it, allowing for clearer separation of concerns.

Async/Await vs React Futures

Lets take a scenario where we want to show the active groups of a user. Here's what it would look like using async/await:

const ActiveGroups = () => {
  const [activeGroups, setActiveGroups] = useState([]);
  useEffect(() => {
    const getActiveGroups = async () => {

      // Example of a complex data-fetching requirement. We are getting active groups by
      // fetching the posts of each group and seeing if there are posts in the last three days
      let groups = await fetchGroupsBelongingToUser('Tom');
      const groupPosts = await Promise.all(groups.map(fetchGroupPosts));
      groups = groups.filter((group, index) => {
        const wasPostedOnRecently = groupPosts[index].some(
          post => post.daysAgoPosted < 3
        );
        return wasPostedOnRecently;
      });

      setActiveGroups(groups);
    };
    getActiveGroups();
  }, []);

  return groups.length > 0 
    ? <ul>{groups.map(group => <li>{group.name}</li>)}</ul>
    : <div>Loading...</div>;
};

And here is the same example using React Futures:

import { futureArray } from "react-futures";

const FutureGroups = futureArray(fetchGroupsBelongingToUser);
const FuturePosts = futureArray(fetchGroupPosts);

const activeGroups = new FutureGroups('Tom').filter(group => {
  const groupPosts = new FuturePosts(group); //fetch posts
  return groupPosts.some(post => post.daysAgoPosted < 3);
});

const ActiveGroups = () => {
  const [groups, setGroups] = useState(activeGroups);
  return <ul>
        {groups.map(group => <li>{group.name}</li>)}
      </ul>
};

This example demonstrates several benefits of React Futures:

  • With React Futures asynchronicity is transparent; a future can be used the same way that a native object or array can be used. They can also be used in other future operations, see how FuturePosts is used in filter to collect activeGroups.
  • With React Futures the manipulation and construction of asynchronous data can be done completely outside render if needed. None of the construction code needs to be located inside the component.

Usage constraints

There are 3 constraints that you should be aware of and their workarounds.

1. Getters are only allowed in render

Due to how Concurrent Mode works, suspense operations (i.e. getters) are only allowed in the render function. For example:

const blogs = FutureBlogs()

const first = blogs[0] // Error: suspense not allowed outside render

const App = () => {

  const first = blogs[0] // suspend!

  return ...
}

Keep in mind that on handlers are not considered a part of render, even though they may be located within the render function.

To work around this, React Futures provides utilities that defer evaluation of getters until suspense (see Suspense operations outside render and Using React Futures with third party libraries). In brief, you should wrap getters performed outside render in lazyArray or lazyObject.

2. No mutable calls inside of render

Operations that mutate a future array or object, like Object.assign, are not allowed within render and should be replaced with their immutable equivalents. To alleviate this constraint, all future object constructor static methods have been made immutable. For example, <Future Class>.assign is an immutable, deferred version of Object.assign:

import {futureObject} from 'react-futures'

const FutureUser = futureObject(...)

const dave = new FutureUser('Dave')

const newDave = Object.assign(dave, , { foo: 'bar' }) // okay
const newDave2 = FutureUser.assign(dave, { foo: 'bar' }) // okay

const App = () => {
  const newDave = Object.assign(dave, { foo: 'bar' }) // Error!!
  const newDave2 = FutureUser.assign(dave, { foo: 'bar' }) // okay

  return ...
}

3. Global ban on specific functions

Some operations are both mutable and are getters, like array.push and array.unshift. These operations are prohibited globally.

For a complete reference of constraints, see the Caveats section.

Example snippets

Object iteration

Object iteration with native types is normally done with Object.entries and Object.fromEntries, but with a future this would suspend since Object.entries is a getter. You should instead use the new <Future Class>.entries and <Future Class>.fromEntries, which will defer evaluation until necessary. For example:

import { futureObject } from "react-futures";
const FutureUser = futureObject(() => fetch("/user").then(res => res.json()));

const user = new FutureUser();

const uppercaseUserEntries = FutureUser.entries(user) //lazy
                              .map(([key, value]) => ({ // lazy
                                [key]: value.toUpperCase(),
                              }));

const uppercaseUser = FutureUser.fromEntries(uppercaseUserEntries); // lazy

All future object static methods are deferred and immutable variants of Object static methods, so they can used both in and out of render.

Suspense operations outside render

Sometimes it's useful to access properties on a future or perform a suspense operation outside of render. An example of this is transforming properties on a future. However, accessing a property will throw an error if done outside render.

const dave = new FutureUser("Dave");

dave.props = dave.props // Error: suspense operations not allowed outside render
               .sort((a, b) => a - b);

To achieve this, use lazyArray or lazyObject to suspend evaluation.

import { futureObject, lazyArray } from "react-futures";

const FutureUser = futureObject(fetchUser);
const dave = new FutureUser("Dave");

dave.props = lazyArray(() => dave.props) //=> future array
               .sort((a, b) => a - b); // lazy

The above snippet suspends evaluation of the dave.props getter until a suspense operation is performed on dave.props inside render. lazyArray returns a future array and an lazyObject returns a future object.

Sometimes performing a suspense operation is inside an on handler is desired, but suspense operations are illegal in on handlers as well.

const dave = new FutureUser("Dave");

const App = () => {
  const [user, setUser] = useState(dave);
  return (
    <>
      <input
        type="text"
        onChange={e => {
          // spread operator on `user` errors since spreading is a suspense operation
          setUser({ ...user, name: e.target.value });
        }}
      />
    </>
  );
};

To accomplish this, we can use the lazyObject from above.

// Using `lazyObject`
const dave = new FutureUser('Dave');

const App = () => {
  const [user, setUser] = useState(dave)

  return <>
  <input type="text" onChange={e => {
    const newUser = lazyObject(() => ({...user, name: e.target.value}))) //=> future object
    setUser(newUser)
  }} />
  </>
}

Alternatively, we can use the getRaw function to force a suspend and get the raw object:

// Using `getRaw`
import { getRaw } from 'react-futures';

const dave = new FutureUser('Dave');

const App = () => {
  const [user, setUser] = useState(getRaw(dave)) // suspends here and gets raw value

  return <>
  <input type="text" onChange={e => {
    const newUser = {...user, name: e.target.value} // can spread, since it's just a normal object
    setUser(newUser)
  }} />
  </>
}

Using with third party libraries (ramda, lodash, etc.)

Third party libraries that inspect the contents of input parameters will suspend if passed in a future. To prevent this use lazyArray and lazyObject. These methods lazily evaluate array and object returning functions respectively.

Lets take a look at an example using lodash's _.cloneDeep. If you pass a future in the function, it would suspend since _.cloneDeep iterates through the properties of the future.

import _ from 'lodash'

const FutureUser = futureObject(...);

const dave = new FutureUser('Dave');

const daveTwin = _.cloneDeep(dave); // Error: can not suspend outside render

To allow this, use the lazyObject to defer evaluation of _.cloneDeep

import _ from 'lodash'
import {lazyObject, futureObject} from 'react-futures'

const FutureUser = futureObject(...);

const dave = new FutureUser('Dave');

const daveTwin = lazyObject(() => _.cloneDeep(dave)) //=> future object

// continue iterating as you would a future
const result = FutureUser.entries(daveTwin) 
                       .map(...)

lazyObject defers the evaluation of the object returning operation until a suspense operation takes place.

lazyArray works the same way for arrays. Here's an example using ramda's zip function:

import { lazyArray, futureArray } from 'react-futures'
import { zip } from 'ramda'

const FutureFriends = futureArray(...)
const FutureGroups = futureArray(...)

const [friends, groups] = [new FutureFriends, new FutureGroups];

const friendsAndGroups = lazyArray(() => zip(friends, groups)) //=> future array

To defer function composition, you can use ramda's pipeWith or composeWith functions and wrap callbacks in lazyObject and lazyArray:

import { pipeWith, filter, sort } from 'ramda';
import { futureArray, lazyArray } from 'react-futures';

const FutureFriends = futureArray(() => fetch(...))

const pipeFuture = pipeWith((fn, futr) => lazyArray(() => fn(futr)))

const lazyInternationalFriendsByGrade = pipeFuture(
  filter(friend => friend.nationality !== 'USA'),
  sort(function(a, b) { return a.grade - b.grade; })
)

const internationalFriendsByGrade = lazyInternationalFriendsByGrade( new FutureFriends() ) // => future array

Cache invalidation

React Futures use values passed into a future constructor as keys for an in-memory cache, so multiple instantiations with the same constructor will pull from the cache

const dave = new FutureUser("Dave"); // fetches
const jen = new FutureUser("Jen"); // fetches

const App = () => {
  const dave = new Future("Dave"); // pulls from cache
  const jen = new Future("Jen"); // pulls from cache
  const harold = new Future("Harold"); // fetches
};

Since caching is performed using LRU, invalidation is done automatically after the cache reaches a certain size and the key hasn't been used in a while.

To manually invalidate a key, you can use the static method invalidate on the future constructor.

const dave = new FutureUser("Dave"); // fetches;

const App = () => {
  let dave = new FutureUser("Dave"); // pulls from cache

  FutureUser.invalidate("Dave"); // deletes 'Dave' from cache

  dave = new FutureUser("Dave"); // fetches
};

Sometimes it's useful to clear the entire cache, like on a page refresh. This can be accomplished using the static method reset on the future constructor

const dave = new FutureUser("Dave");
const jen = new FutureUser("Jen");
const harold = new FutureUser("Harold");
const App = () => {
  useEffect(() => {
    return () => FutureUser.reset(); // clears 'Harold', 'Jen', and 'Dave' from cache
  }, []);
};

Fetching on component mount

Sometimes it's desirable to fetch whenever a component is mounted, similar to how you would in the good old days with fetch and componentDidMount. To achieve this with futures, use useEffect to invalidate the cache on unmount.

const useUser = name => {
  const user = new FutureUser(name); // fetches on first render
  useEffect(() => {
    return () => FutureUser.invalidate(name) // invalidates fetch
  }, [])

  return user
}

const App = () => {
  const user = useUser('Dave');

  return ...
}

With classes the invalidation can be placed inside componentWillUnmount

class App extends React.Component {
  componenDidMount() {
    const user = new FutureUser('Dave') // fetches
    this.setState(() => ({ user }))
  }

  componentWillUnmount() {
    FutureUser.invalidate('Dave') // invalidates cache
  }

  render() {
    return ...
  }
}

Prefetching

One of the focuses of suspense and concurrent mode is 'prefetching' which is fetching data way before we need it. This is great for performance as it shortens the percieved wait time of fetched data.

There is no explicit "prefetch" api in React Futures, fetching occurs whenever a future is instantiated. To prefetch simply instantiate the future outside of render or within a parent component.

const user = new FutureUser('Dave') // instantiating outside of render will prefetch 'user' as file parses

const App = () => {
  const friends = new FutureFriends('Dave') // prefetch in parent component
  const family = new FutureFamily('Dave') // prefetch in parent component
  const currentPage = usePageNavigation()
  return <>
    {
      currentPage === 'family' ? <Family family={family} /> : // 'family' suspended by <Family />
      currentPage === 'friends' ? <Friends friends={friends}> : null // 'friends' suspended by <Friends />
    }
  </>
}

Logging

console.log with a future will log a proxy, which is probably not what you want. To log the contents of the future use either toPromise or getRaw.

import { getRaw, toPromise } from 'react-futures';
...

toPromise(future)
  .then(console.log)


const App = () => {
  console.log(getRaw(future)); 
  return <div></div>
}

If future has any nested futures, those will not be visible with toPromise or getRaw. Here is an example of how you could implement getRawDeep for deep logging.

import { isFuture, getRaw } from 'react-futures';

const getRawDeep = future => {
  if(isFuture(future)) {
    const raw = getRaw(future);
    return getRawDeep(raw);
  }
  if(Array.isArray(future)) {
    return future.map(getRawDeep);
  } else if(typeof future === 'object' && future !== null) {
    return Object.fromEntries(
            Object.entries(future).map(([key, value]) => [key, getRawDeep(value)])
          )
  }
  return future;
}

Using with graphql

Coming soon...

Caveats

Operation constraints

As a rule of thumb, mutable operations are constrained to outside render and suspense operations are constrained to inside render. For suspense operation workarounds see Suspense operations outside render and Using React Futures with third party libraries.

Consider moving mutable operations outside render or using an immutable variant instead. All future object constructor static method have been made immutable and lazy.

Certain operations are forbidden globally since they are both mutable and they inspect the contents of the array. array.push is mutable, for example, prohibiting it from being used in render, and it synchronously returns the length of the array, prohibiting it from being used outside render.

Other operations are tbd since it is uncertain what the use cases for these methods are vs. Object static methods. Depending on how they can be useful, the implementation can vary significantly. Click below for a complete reference of constraints

lazyObject and lazyArray in reassignment

Using lazyObject and lazyArray to perform a reassignment inside a loop can lead to an unexpected error. This is because the right hand side does not evaluate first since lazyObject and lazyArray is deferred.

const arr = [];
for(const futureItem of items) {
  arr = lazyArray(() => [...arr, ...futureItem]) // leads to getter loop of `arr` on suspense
}

to avoid this bug, encapsulate the whole block in lazyObject/lazyArray

const arr = lazyArray(() => {
  let temp = []
  for(const futureItem of items) {
    temp = [...temp, ...futureItem]
  }
  return temp;
})

API Reference

The API reference is in another castle