The need for sewing
I have always cared a lot about making code easy to change, but also about not letting “this is hard to change” be the reason to not implement some cool feature our users really want.
But sometimes code really is difficult to change. Sometimes it’s big and complex, and so it takes time to go in and understand it, to even know where to change it. Other times, it might be small but it is so tightly coupled throughout that any place you touch it, something breaks. One step forward, two steps back.
I first learned about the concept of Seams from Michael Feathers’ book “Working Effectively with Legacy Code”. If we want to be effective about changing code, while reducing risk and actually getting things shipped, it makes sense that we learn strategies and recipes to get that stuff done, instead of reinventing the wheel every time. That’s what seams are about. There are different types and ways to introduce them, but the idea is that you have a toolkit of strategies to change whatever code needs to be changed.
Seam: “a place where you can alter behavior in your program without editing in that place”.
— Michael Feathers, “Working Effectively with Legacy Code”
Object Seams
I personally use Object Seams the most in React. These come from the Object Oriented paradigm, where your function could depend on a particular class, but thanks to inheritance, get passed an object in from a class that inherits from it, changing its behavior.
In the React world, using objects and inheritance is quite rare, but there is a
way in which Object seams are present throughout: Props and Context! When
you create a new Context in React (or declare a prop) the most you can say, if
you’re using Typescript, is what type you want for it. But at the end of the
day, if the Provider for this context changes its value
, then the behavior of
your component will change.
In the following example, we create a new TranslatorContext
, providing a function that will map string keys (such as greeting
or profileScreenTitle
) to user-readable strings ("Hey, there!"
, or "My Profile"
).
type StringKey = "greeting" | "profileScreenTitle" | "...";
export type Translator = (key: StringKey, ...params: string[]) => string;
const TranslatorContext = createContext<Translator>();
export const useTranslator = () => useContext(TranslatorContext);
// Inside a component
export const HomeScreen = ({ navigation }: HomeScreenProps) => {
const t = useTranslator();
const { data: profile } = useProfileQuery();
const onPress = () => {};
return (
<View>
<Text>{t("profileScreenTitle")}</Text>
<Text>{t("...", profile.name)}</Text>
<Button onPress={onPress} title={t("greeting")} />
</View>
);
};
But you should note that nothing is said in either the Context
itself, much less
by the HomeScreen
, what the behavior of that translator function is. One
could think of the usual object mapping from string to string, but this is only
an assumption, and it is key to note that we could very well have very different
functions provided at runtime, through which our component will present very
differently.
// Inject a different translator
const localTranslator: Translator = (key, ...params) => {
const rawString = translations[key];
return rawString.replace(/\{(\d+)\}/g, (_, index) => params[index]);
};
const serviceTranslator: Translator = (key, ...params) => {
return localizationService.translate(key, ...params);
};
const aiTranslator: Translator = (key, ...params) => {
const userLanguageCode = getDeviceLanguage();
const raw = translations[key];
const english = raw.replace(/\{(\d+)\}/g, (_, index) => params[index]);
return translateWithAI(english, userLanguageCode);
};
Here, localTranslator
is probably what you thought of first. But
serviceTranslator
, a function that calls to an external API where folks on the
product team can change the application copy at will, is just as valid. And so
is aiTranslator
, a call to a machine learning model that translates the base
English strings into the users set language during runtime.
It matters not what you decide to go with, or what’s more efficient. What
matters is that by defining three different functions (and there are infinitely
many more) you can change a huge part of how HomeScreen
behaves without
changing a single
line.
Calling useContext()
, with it not being known what value
you will get
back, is an example of a Seam, a place in code where the behavior can be
altered, without ever changing that component.
Enabling Point
I want to note something else when it comes to vocabulary. Seams are the place
where you open your component up to change, you allow behavior to be determined
from the outside, without altering that components code again[1]. But what
about actually making the change? With Context, this is quite clearly the role
of <Context.Provider>
and its value
prop — be it changing the old one or
wrapping the component you want to be changed in a new one.
This is called an Enabling Point. Enabling Points describe the places where by making a change you will cause a change in behavior in a separate component. Seams open your component to change. Enabling Points make that change happen.
As a side note, I know the whole “I change code here, behavior changes someplace else” can be scary, but the key is that Seams and Enabling Points are all about explicit points to introduce change. The problem with that first case is that you do not expect behavior change to happen, whereas for Seams it’s quite the opposite, which is why they are so powerful.
Adding Context Seams
Ok, cool, I like Seams. Where should I add some?
Nowhere, not yet.
Just like any form of “refactor”, my thought is generally that these should only happen when they are necessary to do so. Adding Seams or otherwise “cleaning up” code just to “make it pretty” is a really bad idea, not only because it’s wasteful in the economic sense, but because you really have no idea what changes your code needs in the future, and so you are very likely wrong about the place you are cleaning up.
Now, this is not to say there is no place for refactor, but your goal should be clear. You might be cleaning up a service because you’re modernizing the stack (and there are a lot of reasons for it) and need to replace one of your dependencies. You could be refactoring a lot of your code to add Localization support. Or you could be changing the way you call queries to improve caching and make your application faster. All of these, and more, are good reasons. I’m only saying you should have a reason.
Good to go? Then let’s go.
Adding a Context Seam in React is actually not that bad, and there’s a
lesser-known superpower in the Context API to allow it. React.createContext
takes not only a type argument for the type of the value you’ll be providing but
also an argument for a “default” value. I’ve seen many codebases provide null
or undefined
here, and error out in runtime if you ever use a component
without a Context.Provider
to stop it.
This is all good. But if you ever give it a useful “default” value, then you can
make use of useContext
without yet caring about adding a sensible Provider.
This can be useful if you are right now referring to a global object through
your component directly (like a t: Translator
), and want to instead get that
via a context so you can change it later.
import t from "../services/localization";
export const HomeScreen = () => {
return (
<View>
<Text>{t("profileScreenTitle")}</Text>
{/* ... */}
</View>
);
};
If you instead do the following, you have now opened up HomeScreen
to changes through a Provider, introduced a seam, and forgotten about importing t
directly, as useContext
will still use that same t
until you add some Providers in.
import t from "../services/localization";
const TranslatorContext = createContext(t);
export const useTranslator = () => useContext(TranslatorContext);
Try it out!
There’s a lot more than what I can cover here, both for Object, Prop, and Context Seams, and for Seams in general. But I think the best way to learn these things is to practice. Do some more reading on the topic (lots of good books[2] and articles[3]) and then try it out in your codebases. I am sure there is room for improvements, and places in code you know you want to add some more testing to, or that otherwise need to be opened to change. Jump in and try adding Seams, you will that doing this before actually adding the new behavior, will make the whole process feel a lot simpler, by splitting the task of making the change from the task of preparing for this change.
Open-Closed Principle anyone?! ↩︎
There are a lot of books I could recommend here, but here’s a quick two.
99 Bottles of OOP is my personal favorite. Its structure is quite good, guiding the reader through adding a feature to some tightly coupled code, refactoring and opening it up in the process. The main teaching here? You can always perform a multi-file, multi-line refactor such that, by changing one line at a time, the code is always in a state where (1) code compiles and (2) tests pass. It delves a lot into OOP, but that teaching, and others, you can apply to React and a more functional style too.
Working Effectively with Legacy Code is another really good one, much more specific to what we are talking about here. This is a great read more on strategies to add Seams to code and change it effectively, in particular in those cases where test suites are effectively non-existent and requirements might be unclear. ↩︎
The Patterns of Legacy Displacement series (ongoing) is filled with patterns to replace old code and services with similar strategies to the ones presented here, but oriented to the server. I still think there is a lot you can bring into the frontend world though. ↩︎