Replacing Redux with Relay

Matt Krick
4 min readFeb 27, 2018

It’s been almost 2 years since I wrote Replacing Relay with Redux. Since then, Relay added subscriptions, a build-time compiler, a sensible mutations API, and a game-changing client schema. That puts me in an awkward position. On the one hand, we JavaScript developers get a bad wrap for declaring technology as antiquated the moment something new comes along; but on the other hand, COOL NEW TECH! So with that said, Redux is out and Relay is in. I’m serious. Stop laughing.

Credit to Keith Pitt

The Business Case

Too much boilerplate, team members never documenting their action creator APIs, the ebbing battle of connecting to smart containers vs. dumb components, code-splitting reducers, memoizing selectors, merging local and domain state, async actions, accidental mutability. If you’ve ever written a moderately complex Redux app, you know the struggle is real. If you’re just starting out and wonder why it’s so complicated, you’ve probably just been told to hush or given some sage hippie advice like

Thankfully, the community has rallied together to invent some pretty neat workarounds. Need async functionality? Plop a generator in your middleware and call it a saga. Need to memoize? Use reselect. Need to eliminate boilerplate? Pick your favorite action creator package that turns 20 simple lines into 10 confusing ones. Heck, I even jumped on the bandwagon and wrote a store enhancer so you had a GraphQL-esque documented API in your devtools. At the end of the day, Redux worked and the code shipped; but I’d be lying if I said I wasn’t tempted to turn React’s new Context API into my own simple state store. Thankfully, as of v1.5.0, Relay is shipping with a client schema, and my code is looking a lot cleaner.

Using the Client Schema

As of today, the client schema is “undocumented”, which is a facebook alias for “freakin sweet”. Getting it running is simple:

Write the schema. While you can’t create new types exclusively for the client (yet?) you can use types you created in your server schema. For this example, imagine contentFilter is an input that filters out tasks that don’t include the supplied contentText:

extend type Team {
contentFilter: String
}

extend type Task {
contentText: String
}

This is where the real magic is. If I were using Redux, I’d create a TeamReducer and TaskReducer. Then, since I enjoy splitting code & splitting headaches, I’d asynchronously add those reducers to the store when the dependent components mounted. With Relay, I request these fields just like any other field in my fragment. No extra connect(), no runtime errors because the fragment is compiled at build time, no “dumb component” debate, and the code spliting comes free with the compiled query.

Compile the schema. Adding the client schema is as easy as adding an arg:

relay-compiler --schema serverSchema.graphql --client-schema clientSchema.graphql

Mutate! Do I use deep freeze or immutable.js? Maybe I just trust that my team will use Object.assign correctly for each reducer? Nah, forget it. Thankfully, Relay has it handled with that sweet commitLocalUpdate:

reactRelay.commitLocalUpdate(environment, (store) => {
store.get(teamId).setValue(e.target.value, 'contentFilter');
});

That’s right, the same API you know & love for server mutations is used for local updates. Boilerplate be damned!

Memoizing

If something is calculated from state, does it become state, too? MobX might call it “computed” state. Redux just says “who cares, memoize it”. But how can you handle that with Relay? In our real-world example above, I need to take a draft-js object, turn it into a string of plaintext, and match against a regex. Now, turning 50 draft-js objects into strings on every keystroke isn’t exactly cheap, so it’d be nice to memoize. I could memoize it at the component level by keeping it in state, which is essentially what reselect does, and that works perfectly well! …but what if I could memoize at the app level? With this one weird trick, I can combine my client schema with a custom handlerProvider.

Write the handler. For any field containing a draft-js stringified object, I want to parse it, extract the text, and set a peer contentText field to the result:

const ContentTextHandler = {
update(store, payload) {
const record = store.get(payload.dataID);
const content = record.getValue(payload.fieldKey);
const {blocks} = JSON.parse(content);
const fullText = blocks.map(({text}) => text).join('\n');
record.setValue(fullText, 'contentText');
}
};

Provide the handler. Relay has a default handler (so that’s how they do the magical stuff with connections!). By extending it, the world is your oyster:

const handlerProvider = (handle) => {
switch (handle) {
case 'connection': return ConnectionHandler;
case 'viewer': return ViewerHandler;
case 'contentText': return ContentTextHandler;
default: throw new Error(`Unknown handle ${handle}`);
}
};

For more info, read the docs

Trigger the handler. There’s a secret __clientField directive that Relay provides so you can do magical things. By including it in the query, you can recompute state whenever the query is run.

fragment on Task {
content @__clientField(handle: "contentText")
contentText
}

Now, whenever a query comes in, Relay looks for that handler and generates the plaintext for that decorated field. Pretty sweet! (Note that you’ll still want to call ContentTextHandler.update in your mutations/subscriptions, this only runs on queries.)

Conclusions

And just like that, you can avoid reducer boilerplate hell, use GraphQL to document your local API, get code splitting for free, memoize at the application level, and be the envy of all your friends when you tell them your domain and local state share a single source of truth. Got any other neat patterns for Relay? Let me know in the comments.

--

--