I don't call myself a frontend developer.
To be clear, I know how to do frontend work. I'm trying to keep myself up-to-date with all the newest ways to try to more easily put content on the browser in a performant way. I build web applications, so you have to know how to build a user-facing client to call your backend.
I would call myself a backend developer.
But I definitely wouldn't call myself a frontend developer.
People say the frontend is the "easy" work. I don't. For me, it's a special kind of hell where you have every tool available but none of the right ones. It always seemed to me that it was inevitable that frontend code always ends up in spaghetti and you have to scrap and redo it periodically.
Recently, I found a paradigm that resonates with me and I thought I'd share my thinking. It came to me from a more backend-focused perspective, but the more I think about it, I see it's just good programming practice.
"Architecture: The Lost Years"
One of the talks that influenced me the most was one Robert "Uncle Bob" Martin has that's titled "Architecture: The Lost Years".
In it, he laments how software development evolved over the last few decades:
"I call this talk 'The Lost Years' [...] because from 1993 until now we have forgotten this thing we were on the verge of learning...and do you know what drove it out of our heads? The Web. The Web was so all-consuming [...] and it drove all those ideas out of our brains for [years] and they're just now starting to come back..."
Now, I will say Uncle Bob has some hot takes. There are a lot of people that disagree with him on his version of what "clean code" is (and I'm one of them). I try not to blindly parrot his or anyone's takes, or follow them without a deeper understanding of what they're trying to say. I can't speak to this "Middle Ages of Software Architecture" that he seems to be referring to. Back in 1991 when the Web was new, I think I was just starting to write my first "Hello world" applications in C++. Compared to him and lot of others, I'm a relative n00b.
However, I have noticed that there seems to be a rift between what people say we should do, and what we actually do in practice. For example: why do most of the web app test suites I've ever seen require the web server to be running in order to run the tests?
Unless you're testing for the actual user responses being received (which can be tested independent of the business logic), there's absolutely no reason for the web server to be running. Whenever I've seen it, it's an artifact of treating the web server as the central abstraction of the application. The application then becomes a web server spidered with business logic. In order to test the code, you need to run the web server for the tests and do mock HTTP calls to it. This makes your testing unnecessarily slow because you have the web server in the middle of it.
I've seen the same thing with frontend code. In a lot of cases, you're not able to test the business logic independent of the UI. Why should I have to render the React code to test fetching data from the backend? React is a UI framework; it should never be the central abstraction for my frontend code. However, that's what it seems most frontend clients are: "React apps" instead of applications using React. It also seems that most tutorials (and even some tools!) direct you to "Reactify" all your code, further feeding into this pattern of making React the center of your application.
The "Ports and Adapters" Pattern
So how can we avoid this?
Uncle Bob's architectural recommendation in that talk is remarkably similar to the "ports and adapters" pattern for hexagonal architecture. What Uncle Bob calls the interactor object is a part of the application. It implements a specific interface that defines the functions of how the application object should be interacted with. To use the "ports and adapters" language, the interface is a port, and the interactor is an adapter.
interface Application {
doAppFunction(): void;
}
class Interactor extends Application {
constructor(...) { }
public doAppFunction() {
...execute some application code
}
}
This adapter can be plugged into another bit of code that uses the port, perhaps as an argument to a function or it can be dependency injected at creation time.
class UI {
constructor(app: Application) { this.app = app; }
someUIFunction() {
...
this.app.doAppFunction();
... do other UI code
}
}
This way, the UI can do application-specific functions via the Application
interface but the UI code does not contain any of that code. This allows you to test the Interactor
object independently from the UI, meaning you don't have to run the UI and also you don't have to run the app to do UI unit tests.
Working with React
So how would this work with React?
One way to do this is to make a React Context
that holds an Application
object and then just fetch the object with useContext
whenever you need to interact with something outside of React:
const AppContext = createContext<Application>(undefined);
export const useApplication = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error("useApplication must be used within a ApplicationProvider");
}
return context;
};
export const ApplicationProvider = (props) => {
const { children, value } = props;
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
You can create the Interactor
object just before you render your React application:
const application = new Interactor();
export const renderApp = () => {
const root = document.getElementById("root");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<ApplicationProvider value={application}>
<MyReactApp />
</ApplicationProvider>
</React.StrictMode>,
);
};
Now you can use the hook in the children to get access to the Application
object:
export const MyReactApp = () => {
const app = useApplication();
app.doAppFunction();
return <> ... </>;
};
This pattern allows you to separate your UI from what essentially are your models and controllers. You can now work with app state and not have to bind it with React's UI state, which gives you a lot of control. However, you can still use tools like @tanstack/query
to control overzealous calls to your application object.
Your Mileage May Vary
I know I'm going against the grain on this one.
I'm probably going to get a lot of comments like, "You shouldn't do that because ___ and _____, you n00b!"
And that's okay.
I don't claim to be a frontend developer.
But I will say my frontend code became a lot more simple once I started using this pattern. This same thing actually happened when I started doing this with my backend code as well. I would inject my app dependencies into my app object, and then I would inject my app object as a dependency to my REST/GraphQL/gRPC code. It abstracted a lot of the unnecessary details away and kept my concerns separate.
Try it out. You might like it.
Top comments (0)