Navigate back to the homepage

Records & Tuples for React

Sébastien Lorber
July 31st, 2020 · 8 min read

Waiting for Theo joke

Hey 👋

Records & Tuples, a very interesting proposal, has just reached stage 2 at TC39.

They bring deeply immutable data structures to JavaScript.

But don’t overlook their equality properties, that are VERY interesting for React.

A whole category of React bugs are related to unstable object identities:

  • Performance: re-renders that could be avoided
  • Behavior: useless effect re-executions, infinite loops
  • API surface: unability to express when a stable object identity matters

I will explain the basics of Records & Tuples, and how they can solve real world React issues.



Records & Tuples 101

This article is about Records & Tuples for React. I’ll only cover the basics here.

They look like regular Objects and Arrays, with a # prefix.

1const record = #{a: 1, b: 2};
2
3record.a;
4// 1
5
6const updatedRecord = #{...record, b: 3};
7// #{a: 1, b: 3};
8
9
10const tuple = #[1, 5, 2, 3, 4];
11
12tuple[1];
13// 5
14
15const filteredTuple = tuple.filter(num => num > 2)
16// #[5, 3, 4];

They are deeply immutable by default.

1const record = #{a: 1, b: 2};
2
3record.b = 3;
4// throws TypeError

They can be seen as “compound primitives”, and can be compared by value.

VERY IMPORTANT: two deeply equal records will ALWAYS return true with ===.

1{a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}
2// with objects => false
3
4#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}
5// with records => true

We can somehow consider that the identity of a Record is its actual value, like any regular JS primitive.

This property has deep implications for React, as we will see.

They are interoperable with JSON:

1const record = JSON.parseImmutable('{a: 1, b: [2, 3]}');
2// #{a: 1, b: #[2, 3]}
3
4JSON.stringify(record);
5// '{a: 1, b: [2, 3]}'

They can only contain other records and tuples, or primitive values.

1const record1 = #{
2 a: {
3 regular: 'object'
4 },
5};
6// throws TypeError, because a record can't contain an object
7
8const record2 = #{
9 b: new Date(),
10};
11// throws TypeError, because a record can't contain a Date
12
13const record3 = #{
14 c: new MyClass(),
15};
16// throws TypeError, because a record can't contain a class
17
18const record4 = #{
19 d: function () {
20 alert('forbidden');
21 },
22};
23// throws TypeError, because a record can't contain a function

Note: you may be able to add such mutable values to a Record, by using Symbols as WeakMap keys (separate proposal), and reference the symbols in records.

Want more? Read the proposal directly, or this article from Axel Rauschmayer.


Records & Tuples for React

React developers are now used to immutability.

Every time you update some piece of state in an immutable way, you create new object identities.

Unfortunately, this immutability model has introduced a whole new class of bugs, and performance issues in React applications. Sometimes, a component works correctly and in a performant way, only under the assumption that props preserve identities as most as they can over time.

I like to think about Records & Tuples as a convenient way to make object identities more “stable”.

Let’s see how this proposal will impact your React code with practical use cases.

Note: there is a Records & Tuples playground, that can run React.

Immutability

Enforcing immutability can be achieved with recursive Object.freeze() calls.

But in practice, we often use the immutability model without enforcing it too strictly, as it’s not convenient to apply Object.freeze() after each update. Yet, mutating the React state directly is a common mistake for new React developers.

The Records & Tuples proposal will enforce immutability, and prevent common state mutation mistakes:

1const Hello = ({ profile }) => {
2 // prop mutation: throws TypeError
3 profile.name = 'Sebastien updated';
4
5 return <p>Hello {profile.name}</p>;
6};
7
8function App() {
9 const [profile, setProfile] = React.useState(#{
10 name: 'Sebastien',
11 });
12
13 // state mutation: throws TypeError
14 profile.name = 'Sebastien updated';
15
16 return <Hello profile={profile} />;
17}

Immutable updates

There are many ways to perform immutable state updates in React: vanilla JS, Lodash set, ImmerJS, ImmutableJS…

Records & Tuples support the same kind of immutable update patterns that you use with ES6 Objects and Arrays:

1const initialState = #{
2 user: #{
3 firstName: "Sebastien",
4 lastName: "Lorber"
5 }
6 company: #{
7 name: "Lambda Scale",
8 }
9};
10
11
12const updatedState = {
13 ...initialState,
14 company: {
15 ...initialState.company,
16 name: 'Freelance',
17 },
18};

So far, ImmerJS has won the immutable updates battle, due to its simplicity to handle nested attributes, and interoperability with regular JS code.

It is not clear how Immer could work with Records & Tuples yet, but it’s something the proposal authors are exploring.

Michael Weststrate himself has highlighted that a separate but related proposal could make ImmerJS unnecessary for Records & Tuples:

1const initialState = #{
2 counters: #[
3 #{ name: "Counter 1", value: 1 },
4 #{ name: "Counter 2", value: 0 },
5 #{ name: "Counter 3", value: 123 },
6 ],
7 metadata: #{
8 lastUpdate: 1584382969000,
9 },
10};
11
12// Vanilla JS updates
13// using deep-path-properties-for-record proposal
14const updatedState = #{
15 ...initialState,
16 counters[0].value: 2,
17 counters[1].value: 1,
18 metadata.lastUpdate: 1584383011300,
19};

useMemo

In addition to memoizing expensive computations, useMemo() is also useful to avoid creating new object identities, that might trigger useless computations, re-renders, or effects executions deeper in the tree.

Let’s consider the following use-case: you have an UI with multiple filters, and want to fetch some data from the backend.

Existing React code-bases might contain code such as:

1// Don't change apiFilters object identity,
2// unless one of the filter changes
3// Not doing this is likely to trigger a new fetch
4// on each render
5const apiFilters = useMemo(
6 () => ({ userFilter, companyFilter }),
7 [userFilter, companyFilter],
8);
9
10const { apiData, loading } = useApiData(apiFilters);

With Records & Tuples, this simply becomes:

1const {apiData,loading} = useApiData(#{ userFilter, companyFilter })

useEffect

Let’s continue with our api filters use-case:

1const apiFilters = { userFilter, companyFilter };
2
3useEffect(() => {
4 fetchApiData(apiFilters).then(setApiDataInState);
5}, [apiFilters]);

Unfortunately, the fetch effect gets re-executed, because the identity of the apiFilters object changes every time this component re-renders. setApiDataInState will trigger a re-render, and you will end up with an infinite fetch/render loop.

This mistake is so common across React developers that there are thousand of Google search results for useEffect + “infinite loop”.

Kent C Dodds even created a tool to break infinite loops in development.

Very common solution: create apiFilters directly in the effect’s callback:

1useEffect(() => {
2 const apiFilters = { userFilter, companyFilter };
3 fetchApiData(apiFilters).then(setApiDataInState);
4}, [userFilter, companyFilter]);

Another creative solution (not very performant, found on Twitter):

1const apiFiltersString = JSON.stringify({
2 userFilter,
3 companyFilter,
4});
5
6useEffect(() => {
7 fetchApiData(JSON.parse(apiFiltersString)).then(
8 setApiDataInState,
9 );
10}, [apiFiltersString]);

The one I like the most:

1// We already saw this somewhere, right? :p
2const apiFilters = useMemo(
3 () => ({ userFilter, companyFilter }),
4 [userFilter, companyFilter],
5);
6
7useEffect(() => {
8 fetchApiData(apiFilters).then(setApiDataInState);
9}, [apiFilters]);

There are many fancy ways to solve this problem, but they all tend to become annoying, as the number of filters increase.

use-deep-compare-effect (from Kent C Dodds) is likely the less annoying, but running deep equality on every re-render has a cost I’d prefer not pay.

They are much more verbose and less idiomatic than their Records & Tuples counterpart:

1const apiFilters = #{ userFilter, companyFilter };
2
3useEffect(() => {
4 fetchApiData(apiFilters).then(setApiDataInState);
5}, [apiFilters]);

Props and React.memo

Preserving object identities in props is also very useful for React performances.

Another very common performance mistake: create new objects identities in render.

1const Parent = () => {
2 useRerenderEverySeconds();
3 return (
4 <ExpensiveChild
5 // someData props object is created "on the fly"
6 someData={{ attr1: 'abc', attr2: 'def' }}
7 />
8 );
9};
10
11const ExpensiveChild = React.memo(({ someData }) => {
12 return <div>{expensiveRender(someData)}</div>;
13});

Most of the time, this is not a problem, and React is fast enough.

But sometimes you are looking to optimize your app, and this new object creation makes the React.memo() useless. Worst, it actually makes your application a little bit slower (as it now has to run an additional shallow equality check, always returning false).

Another pattern I often see in client code-bases:

1const currentUser = { name: 'Sebastien' };
2const currentCompany = { name: 'Lambda Scale' };
3
4const AppProvider = () => {
5 useRerenderEverySeconds();
6
7 return (
8 <MyAppContext.Provider
9 // the value prop object is created "on the fly"
10 value={{ currentUser, currentCompany }}
11 />
12 );
13};

Despite the fact that currentUser or currentCompany never gets updated, your context value changes every time this provider re-renders, which trigger re-renders of all context subscribers.

All these issues can be solved with memoization:

1const someData = useMemo(
2 () => ({ attr1: 'abc', attr2: 'def' }),
3 [],
4);
5
6<ExpensiveChild someData={someData} />;
1const contextValue = useMemo(
2 () => ({ currentUser, currentCompany }),
3 [currentUser, currentCompany],
4);
5
6<MyAppContext.Provider value={contextValue} />;

With Records & Tuples, it is idiomatic to write performant code:

1<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
1<MyAppContext.Provider value={#{ currentUser, currentCompany }} />;

Fetching and re-fetching

There are many ways to fetch data in React: useEffect, HOC, Render props, Redux, SWR, React-Query, Apollo, Relay, Urql, …

Most often, we hit the backend with a request, and get some JSON data back.

To illustrate this section, I will use react-async-hook, my own very simple fetching library, but this applies to other libraries as well.

Let’s consider a classic async function to get some API data:

1const fetchUserAndCompany = async () => {
2 const response = await fetch(
3 `https://myBackend.com/userAndCompany`,
4 );
5 return response.json();
6};

This app fetches the data, and ensure this data stays “fresh” (non-stale) over time:

1const App = ({ id }) => {
2 const { result, refetch } = useAsync(
3 fetchUserAndCompany,
4 [],
5 );
6
7 // We try very hard to not display stale data to the user!
8 useInterval(refetch, 10000);
9 useOnReconnect(refetch);
10 useOnNavigate(refetch);
11
12 if (!result) {
13 return null;
14 }
15
16 return (
17 <div>
18 <User user={result.user} />
19 <Company company={result.company} />
20 </div>
21 );
22};
23
24const User = React.memo(({ user }) => {
25 return <div>{user.name}</div>;
26});
27
28const Company = React.memo(({ company }) => {
29 return <div>{company.name}</div>;
30});

Problem: you have used React.memo for performance reasons, but every time the re-fetch happens, you end up with a new JS object, with a new identity, and everything re-renders, despite the fetched data being the same as before (deeply equal payloads).

Let’s imagine this scenario:

  • you use the “Stale-While-Revalidate” pattern (show cached/stale data first, then refresh data in the background)
  • your page is complex, render intensive, with lots of backend data being displayed

You navigate to a page, that is already expensive to render the first time (with cached data). One second later, the refreshed data comes back. Despite being deeply equal to the cached data, everything re-renders again. Without Concurrent Mode and time slicing, some users may even notice their UI freezing for a few hundred milliseconds.

Now, let’s convert the fetch function to return a Record instead:

1const fetchUserAndCompany = async () => {
2 const response = await fetch(
3 `https://myBackend.com/userAndCompany`,
4 );
5 return JSON.parseImmutable(await response.text());
6};

By chance, JSON is compatible with Records & Tuples, and you should be able to convert any backend response to a Record, with JSON.parseImmutable.

Note: Robin Ricard, one of the proposal authors, is pushing for a new response.immutableJson() function.

With Records & Tuples, if the backend returns the same data, you don’t re-render anything at all!

Also, if only one part of the response has changed, the other nested objects of the response will still keep their identity. This means that if only user.name changed, the User component will re-render, but not the Company component!

I let you imagine the performance impact of all this, considering patterns like “Stale-While-Revalidate” are becoming increasingly popular, and provided out of the box by libraries such as SWR, React-Query, Apollo, Relay…

Reading query strings

In search UIs, it’s a good practice to preserve the state of filters in the querystring. The user can then copy/paste the link to someone else, refresh the page, or bookmark it.

If you have 1 or 2 filters, that’s simple, but as soon as your search UI becomes complex (10+ filters, ability to compose queries with AND/OR logic…), you’d better use a good abstraction to manage your querystring.

I personally like qs: it’s one of the few libraries that handle nested objects.

1const queryStringObject = {
2 filters: {
3 userName: 'Sebastien',
4 },
5 displayMode: 'list',
6};
7
8const queryString = qs.stringify(queryStringObject);
9
10const queryStringObject2 = qs.parse(queryString);
11
12assert.deepEqual(queryStringObject, queryStringObject2);
13
14assert(queryStringObject !== queryStringObject2);

queryStringObject and queryStringObject2 are deeply equal, but they have not the same identity anymore, because qs.parse creates new objects.

You can integrate the querystring parsing in a hook, and “stabilize” the querystring object with useMemo(), or a lib such as use-memo-value.

1const useQueryStringObject = () => {
2 // Provided by your routing library, like React-Router
3 const { search } = useLocation();
4 return useMemo(() => qs.parse(search), [search]);
5};

Now, imagine that deeper in the tree you have:

1const { filters } = useQueryStringObject();
2
3useEffect(() => {
4 fetchUsers(filters).then(setUsers);
5}, [filters]);

This is a bit nasty here, but the same problem happens again and again.

Despite the usage of useMemo(), as an attempt to preserve queryStringObject identity, you will end up with unwanted fetchUsers calls.

When the user will update the displayMode (that should only change the rendering logic, not trigger a re-fetch), the querystring will change, leading to the querystring being parsed again, leading to a new object identity for the filter attribute, leading to the unwanted useEffect execution.

Again, Records & Tuples would prevent such things to happen.

1// This is a non-performant, but working solution.
2// Lib authors should provide a method such as qs.parseRecord(search)
3const parseQueryStringAsRecord = (search) => {
4 const queryStringObject = qs.parse(search);
5
6 // Note: the Record(obj) conversion function is not recursive
7 // There's a recursive conversion method here:
8 // https://tc39.es/proposal-record-tuple/cookbook/index.html
9 return JSON.parseImmutable(
10 JSON.stringify(queryStringObject),
11 );
12};
13
14const useQueryStringRecord = () => {
15 const { search } = useLocation();
16 return useMemo(() => parseQueryStringAsRecord(search), [
17 search,
18 ]);
19};

Now, even if the user updates the displayMode, the filters object will preserve its identity, and not trigger any useless re-fetch.

Note: if the Records & Tuples proposal is accepted, libraries such as qs will likely provide a qs.parseRecord(search) method.

Deeply equal JS transformations

Imagine the following JS transformation in a component:

1const AllUsers = [
2 { id: 1, name: 'Sebastien' },
3 { id: 2, name: 'John' },
4];
5
6const Parent = () => {
7 const userIdsToHide = useUserIdsToHide();
8
9 const users = AllUsers.filter(
10 (user) => !userIdsToHide.includes(user.id),
11 );
12
13 return <UserList users={users} />;
14};
15
16const UserList = React.memo(({ users }) => (
17 <ul>
18 {users.map((user) => (
19 <li key={user.id}>{user.name}</li>
20 ))}
21 </ul>
22));

Every time the Parent component re-renders, the UserList component re-render as well, because filter will always return a new array instance.

This is the case even if userIdsToHide is empty, and AllUsers identity being stable! In such case, the filter operation does not actually filter anything, it just creates new useless array instances, opting out of our React.memo optimisations.

These kind of transformations are very common in React codebase, with operators such as map or filter, in components, reducers, selectors, Redux…

Memoization can solve this, but it’s more idiomatic with Records & Tuples:

1const AllUsers = #[
2 #{ id: 1, name: 'Sebastien' },
3 #{ id: 2, name: 'John' },
4];
5
6const filteredUsers = AllUsers.filter(() => true);
7
8AllUsers === filteredUsers;
9// true

Records as React key

Let’s imagine you have a list of items to render:

1const list = [
2 { country: 'FR', localPhoneNumber: '111111' },
3 { country: 'FR', localPhoneNumber: '222222' },
4 { country: 'US', localPhoneNumber: '111111' },
5];

What key would you use?

Considering both the country and localPhoneNumber are not independently unique in the list, you have 2 possible choices.

Array index key:

1<>
2 {list.map((item, index) => (
3 <Item key={`poormans_key_${index}`} item={item} />
4 ))}
5</>

This always works, but is far from ideal, particularly if the items in the list are reordered.

Composite key:

1<>
2 {list.map((item) => (
3 <Item
4 key={`${item.country}_${item.localPhoneNumber}`}
5 item={item}
6 />
7 ))}
8</>

This solution handle better the list re-orderings, but is only possible if we know for sure that the couple / tuple is unique.

In such case, wouldn’t it be more convenient to use Records as the key directly?

1const list = #[
2 #{ country: 'FR', localPhoneNumber: '111111' },
3 #{ country: 'FR', localPhoneNumber: '222222' },
4 #{ country: 'US', localPhoneNumber: '111111' },
5];
6
7<>
8 {list.map((item) => (
9 <Item key={item} item={item} />
10 ))}
11</>

This was suggested by Morten Barklund.

Explicit API surface

Let’s consider this TypeScript component:

1const UsersPageContent = ({
2 usersFilters,
3}: {
4 usersFilters: UsersFilters,
5}) => {
6 const [users, setUsers] = useState([]);
7
8 // poor-man's fetch
9 useEffect(() => {
10 fetchUsers(usersFilters).then(setUsers);
11 }, [usersFilters]);
12
13 return <Users users={users} />;
14};

This code may or may not create an infinite loop, as we have seen already, depending on how stable the usersFilters prop is. This creates an implicit API contract that should be documented and clearly understood by the implementor of the parent component, and despite using TypeScript, this is not reflected in the type-system.

The following will lead to an infinite loop, but TypeScript has no way to prevent it:

1<UsersPageContent
2 usersFilters={{ nameFilter, ageFilter }}
3/>

With Records & Tuples, we can tell TypeScript to expect a Record:

1const UsersPageContent = ({
2 usersFilters,
3}: {
4 usersFilters: #{nameFilter: string, ageFilter: string}
5}) => {
6 const [users, setUsers] = useState([]);
7
8 // poor-man's fetch
9 useEffect(() => {
10 fetchUsers(usersFilters).then(setUsers);
11 }, [usersFilters]);
12
13 return <Users users={users} />;
14};

Note: the #{nameFilter: string, ageFilter: string} is my own invention: we don’t know yet what will be the TypeScript syntax.

TypeScript compilation will fail for:

1<UsersPageContent
2 usersFilters={{ nameFilter, ageFilter }}
3/>

While TypeScript would accept:

1<UsersPageContent
2 usersFilters={#{ nameFilter, ageFilter }}
3/>

With Records & Tuples, we can prevent this infinite loop at compile time.

We have an explicit way to tell the compiler that our implementation is object-identity sensitive (or relies on by-value comparisons).

Note: readonly would not solve this: it only prevents mutation, but does not guarantee a stable identity.

Serialization guarantee

You may want to ensure that developers on your team don’t put unserializable things in global app state. This is important if you plan to send the state to the backend, or persist it locally in localStorage (or AsyncStorage for React-Native users).

To ensure that, you just need to ensure that the root object is a record. This will guarantee that all the nested attributes are also primitives, including nested records and tuples.

Here’s an example integration with Redux, to ensure the Redux store keeps being serializable over time:

1if (process.env.NODE_ENV === 'development') {
2 ReduxStore.subscribe(() => {
3 if (typeof ReduxStore.getState() !== 'record') {
4 throw new Error(
5 "Don't put non-serializable things in the Redux store! " +
6 'The root Redux state must be a record!',
7 );
8 }
9 });
10}

Note: this is not a perfect guarantee, because Symbol can be put in a Record, and is not serializable.

CSS-in-JS performances

Let’s consider some CSS-in-JS from a popular lib, using the css prop:

1const Component = () => (
2 <div
3 css={{
4 backgroundColor: 'hotpink',
5 }}
6 >
7 This has a hotpink background.
8 </div>
9);

Your CSS-in-JS library receives a new CSS object on every re-render.

On first render, it will hash this object as a unique class name, and insert the CSS. The style object has a different identity for each re-render, and the CSS-in-JS library have to hash it again and again.

1const insertedClassNames = new Set();
2
3function handleStyleObject(styleObject) {
4 // computeStyleHash re-executes every time
5 const className = computeStyleHash(styleObject);
6
7 // only insert the css for this className once
8 if (!insertedClassNames.has(className)) {
9 insertCSS(className, styleObject);
10 insertedClassNames.add(className);
11 }
12
13 return className;
14}

With Records & Tuples, the identity of such a style object is preserved over time.

1const Component = () => (
2 <div
3 css={#{
4 backgroundColor: 'hotpink',
5 }}
6 >
7 This has a hotpink background.
8 </div>
9);

Records & Tuples can be used as Map keys. This could make the implementation of your CSS-in-JS library faster:

1const insertedStyleRecords = new Map();
2
3function handleStyleRecord(styleRecord) {
4 let className = insertedStyleRecords.get(styleRecord);
5
6 if (!className) {
7 // computeStyleHash is only executed once!
8 className = computeStyleHash(styleRecord);
9 insertCSS(className, styleRecord);
10 insertedStyleRecords.add(styleRecord, className);
11 }
12
13 return className;
14}

We don’t know yet about Records & Tuples performances (this will depend on browser vendor implementations), but I think it’s safe to say it will be faster than creating the equivalent object, and then hashing it to a className.

Note: some CSS-in-JS library with a good Babel plugin might be able to transform static style objects as constants at compilation time, but they will have a hard time doing so with dynamic styles.

1const staticStyleObject = { backgroundColor: 'hotpink' };
2
3const Component = () => (
4 <div css={staticStyleObject}>
5 This has a hotpink background.
6 </div>
7);

Conclusion

Many React performance and behavior issues are related to object identities.

Records & Tuples will ensure that object identities are “more stable” out of the box, by providing some kind of “automatic memoization”, and help us solve these React problems more easily.

Using TypeScript, we may be able to express better that your API surface is object-identity sensitive.

I hope you are now as excited as I am by this proposal!

Thank you for reading!

Thanks Robin Ricard, Rick Button, Daniel Ehrenberg, Nicolò Ribaudo, Rob Palmer for their work on this awesome proposal, and for reviewing my article.


If you like it, spread the word on Twitter, Dev, Reddit or HackerNews.

Browser code demos, or correct my post typos on the blog repo

For more content like this, subscribe to This Week In React and follow me on Twitter.

Join This Week In React - Stay up-to-date now!

A weekly newsletter with all the cutting-edge React and React-Native news. Perfect for experienced developers. Stop scrolling on Twitter and receive everything directly in your inbox!

    More articles from Sébastien Lorber

    Using Expo in Gatsby

    ...or how to use cross-platform components in a MDX blog post

    May 11th, 2020 · 3 min read

    Atomic CSS-in-JS

    ...or how to scale your CSS-in-JS.

    April 27th, 2020 · 7 min read
    © 2019–2020 Sébastien Lorber
    Link to $https://twitter.com/sebastienlorberLink to $https://github.com/slorberLink to $https://stackoverflow.com/users/82609/sebastien-lorberLink to $https://www.linkedin.com/in/sebastienlorber/Link to $https://dev.to/sebastienlorberLink to $mailto:[email protected]