Records & Tuples for React, way more than immutability

Records & Tuples for React, way more than immutability

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.

For more content like this, subscribe to my mailing list and follow me on Twitter.


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.

const record = #{a: 1, b: 2};

record.a;
// 1

const updatedRecord = #{...record, b: 3};
// #{a: 1, b: 3};


const tuple = #[1, 5, 2, 3, 4];

tuple[1];
// 5

const filteredTuple = tuple.filter(num => num > 2)
// #[5, 3, 4];

They are deeply immutable by default.

const record = #{a: 1, b: 2};

record.b = 3;
// 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 ===.

{a: 1, b: [3, 4]} === {a: 1, b: [3, 4]}
// with objects => false

#{a: 1, b: #[3, 4]} === #{a: 1, b: #[3, 4]}
// 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:

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

JSON.stringify(record);
// '{a: 1, b: [2, 3]}'

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

const record1 = #{
  a: {
    regular: 'object'
  },
};
// throws TypeError, because a record can't contain an object

const record2 = #{
  b: new Date(),
};
// throws TypeError, because a record can't contain a Date

const record3 = #{
  c: new MyClass(),
};
// throws TypeError, because a record can't contain a class

const record4 = #{
  d: function () {
    alert('forbidden');
  },
};
// 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:

const Hello = ({ profile }) => {
  // prop mutation: throws TypeError
  profile.name = 'Sebastien updated';

  return <p>Hello {profile.name}</p>;
};

function App() {
  const [profile, setProfile] = React.useState(#{
    name: 'Sebastien',
  });

  // state mutation: throws TypeError
  profile.name = 'Sebastien updated';

  return <Hello profile={profile} />;
}

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:

const initialState = #{
  user: #{
    firstName: "Sebastien",
    lastName: "Lorber"
  }
  company: #{
    name: "Lambda Scale",
  }
};


const updatedState = {
  ...initialState,
  company: {
    ...initialState.company,
    name: 'Freelance',
  },
};

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:

const initialState = #{
  counters: #[
    #{ name: "Counter 1", value: 1 },
    #{ name: "Counter 2", value: 0 },
    #{ name: "Counter 3", value: 123 },
  ],
  metadata: #{
    lastUpdate: 1584382969000,
  },
};

// Vanilla JS updates
// using deep-path-properties-for-record proposal
const updatedState = #{
  ...initialState,
  counters[0].value: 2,
  counters[1].value: 1,
  metadata.lastUpdate: 1584383011300,
};

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:

// Don't change apiFilters object identity,
// unless one of the filter changes
// Not doing this is likely to trigger a new fetch
// on each render
const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);

const { apiData, loading } = useApiData(apiFilters);

With Records & Tuples, this simply becomes:

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

useEffect

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

const apiFilters = { userFilter, companyFilter };

useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [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:

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

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

const apiFiltersString = JSON.stringify({
  userFilter,
  companyFilter,
});

useEffect(() => {
  fetchApiData(JSON.parse(apiFiltersString)).then(
    setApiDataInState,
  );
}, [apiFiltersString]);

The one I like the most:

// We already saw this somewhere, right? :p
const apiFilters = useMemo(
  () => ({ userFilter, companyFilter }),
  [userFilter, companyFilter],
);

useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [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:

const apiFilters = #{ userFilter, companyFilter };

useEffect(() => {
  fetchApiData(apiFilters).then(setApiDataInState);
}, [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.

const Parent = () => {
  useRerenderEverySeconds();
  return (
    <ExpensiveChild
      // someData props object is created "on the fly"
      someData={{ attr1: 'abc', attr2: 'def' }}
    />
  );
};

const ExpensiveChild = React.memo(({ someData }) => {
  return <div>{expensiveRender(someData)}</div>;
});

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:

const currentUser = { name: 'Sebastien' };
const currentCompany = { name: 'Lambda Scale' };

const AppProvider = () => {
  useRerenderEverySeconds();

  return (
    <MyAppContext.Provider
      // the value prop object is created "on the fly"
      value={{ currentUser, currentCompany }}
    />
  );
};

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:

const someData = useMemo(
  () => ({ attr1: 'abc', attr2: 'def' }),
  [],
);

<ExpensiveChild someData={someData} />;
const contextValue = useMemo(
  () => ({ currentUser, currentCompany }),
  [currentUser, currentCompany],
);

<MyAppContext.Provider value={contextValue} />;

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

<ExpensiveChild someData={#{ attr1: 'abc', attr2: 'def' }} />;
<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:

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

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

const App = ({ id }) => {
  const { result, refetch } = useAsync(
    fetchUserAndCompany,
    [],
  );

  // We try very hard to not display stale data to the user!
  useInterval(refetch, 10000);
  useOnReconnect(refetch);
  useOnNavigate(refetch);

  if (!result) {
    return null;
  }

  return (
    <div>
      <User user={result.user} />
      <Company company={result.company} />
    </div>
  );
};

const User = React.memo(({ user }) => {
  return <div>{user.name}</div>;
});

const Company = React.memo(({ company }) => {
  return <div>{company.name}</div>;
});

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:

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

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.

const queryStringObject = {
  filters: {
    userName: 'Sebastien',
  },
  displayMode: 'list',
};

const queryString = qs.stringify(queryStringObject);

const queryStringObject2 = qs.parse(queryString);

assert.deepEqual(queryStringObject, queryStringObject2);

assert(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.

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

Now, imagine that deeper in the tree you have:

const { filters } = useQueryStringObject();

useEffect(() => {
  fetchUsers(filters).then(setUsers);
}, [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.

// This is a non-performant, but working solution.
// Lib authors should provide a method such as qs.parseRecord(search)
const parseQueryStringAsRecord = (search) => {
  const queryStringObject = qs.parse(search);

  // Note: the Record(obj) conversion function is not recursive
  // There's a recursive conversion method here:
  // https://tc39.es/proposal-record-tuple/cookbook/index.html
  return JSON.parseImmutable(
    JSON.stringify(queryStringObject),
  );
};

const useQueryStringRecord = () => {
  const { search } = useLocation();
  return useMemo(() => parseQueryStringAsRecord(search), [
    search,
  ]);
};

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:

const AllUsers = [
  { id: 1, name: 'Sebastien' },
  { id: 2, name: 'John' },
];

const Parent = () => {
  const userIdsToHide = useUserIdsToHide();

  const users = AllUsers.filter(
    (user) => !userIdsToHide.includes(user.id),
  );

  return <UserList users={users} />;
};

const UserList = React.memo(({ users }) => (
  <ul>
    {users.map((user) => (
      <li key={user.id}>{user.name}</li>
    ))}
  </ul>
));

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:

const AllUsers = #[
  #{ id: 1, name: 'Sebastien' },
  #{ id: 2, name: 'John' },
];

const filteredUsers = AllUsers.filter(() => true);

AllUsers === filteredUsers;
// true

Records as React key

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

const list = [
  { country: 'FR', localPhoneNumber: '111111' },
  { country: 'FR', localPhoneNumber: '222222' },
  { country: 'US', localPhoneNumber: '111111' },
];

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:

<>
  {list.map((item, index) => (
    <Item key={`poormans_key_${index}`} item={item} />
  ))}
</>

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

Composite key:

<>
  {list.map((item) => (
    <Item
      key={`${item.country}_${item.localPhoneNumber}`}
      item={item}
    />
  ))}
</>

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?

const list = #[
  #{ country: 'FR', localPhoneNumber: '111111' },
  #{ country: 'FR', localPhoneNumber: '222222' },
  #{ country: 'US', localPhoneNumber: '111111' },
];

<>
  {list.map((item) => (
    <Item key={item} item={item} />
  ))}
</>

This was suggested by Morten Barklund.

Explicit API surface

Let's consider this TypeScript component:

const UsersPageContent = ({
  usersFilters,
}: {
  usersFilters: UsersFilters,
}) => {
  const [users, setUsers] = useState([]);

  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);

  return <Users users={users} />;
};

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:

<UsersPageContent
  usersFilters={{ nameFilter, ageFilter }}
/>

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

const UsersPageContent = ({
  usersFilters,
}: {
  usersFilters: #{nameFilter: string, ageFilter: string}
}) => {
  const [users, setUsers] = useState([]);

  // poor-man's fetch
  useEffect(() => {
    fetchUsers(usersFilters).then(setUsers);
  }, [usersFilters]);

  return <Users users={users} />;
};

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:

<UsersPageContent
  usersFilters={{ nameFilter, ageFilter }}
/>

While TypeScript would accept:

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

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:

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

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:

const Component = () => (
  <div
    css={{
      backgroundColor: 'hotpink',
    }}
  >
    This has a hotpink background.
  </div>
);

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.

const insertedClassNames = new Set();

function handleStyleObject(styleObject) {
  // computeStyleHash re-executes every time
  const className = computeStyleHash(styleObject);

  // only insert the css for this className once
  if (!insertedClassNames.has(className)) {
    insertCSS(className, styleObject);
    insertedClassNames.add(className);
  }

  return className;
}

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

const Component = () => (
  <div
    css={#{
      backgroundColor: 'hotpink',
    }}
  >
    This has a hotpink background.
  </div>
);

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

const insertedStyleRecords = new Map();

function handleStyleRecord(styleRecord) {
  let className = insertedStyleRecords.get(styleRecord);

  if (!className) {
    // computeStyleHash is only executed once!
    className = computeStyleHash(styleRecord);
    insertCSS(className, styleRecord);
    insertedStyleRecords.add(styleRecord, className);
  }

  return className;
}

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.

const staticStyleObject = { backgroundColor: 'hotpink' };

const Component = () => (
  <div css={staticStyleObject}>
    This has a hotpink background.
  </div>
);

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 with a Retweet, Reddit or HackerNews.

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

For more content like this, subscribe to my mailing list and follow me on Twitter.

Did you find this article valuable?

Support Sébastien Lorber by becoming a sponsor. Any amount is appreciated!