
I’m a software ninja 🥷 who loves abstractions and spread functional programming everywhere. I've over 2 years of experience and I'm based in Delhi, India🇮🇳
Think of React as a UI library — nothing more than that. Before you write a single component, you need to be able to distinguish between three things: the UI layer, the business/service layer, and the data/state layer. Mixing them is where most complexity comes from.
The Data Layer
The data or state layer lives in the store, and it's always exposed through hooks. Since hooks must live inside components, the goal is to make them atomic and complete — one hook, one concern.
function useIsAuthenticated() {
const isAuthenticated = useUser(state => state.user.isAuthenticated);
return isAuthenticated;
}
The store itself is never exposed directly. Instead, it hides behind a set of small, focused hooks. useIsAuthenticated encapsulates the logic of whether the user is authenticated or not. The consumer doesn't care how we got there.
Now suppose you're not using a Zustand store anymore. Suppose you're in an Electron app where state lives in an electron store:
function useIsAuthenticated() {
const [isAuthenticated, setIsAuthenticated] = useState(
() => eStore.getState().isAuthenticated
);
useEffect(() => {
const unsubscribe = eStore.onDidChange('isAuthenticated', (newValue) => {
setIsAuthenticated(newValue);
});
return () => {
unsubscribe();
};
}, []);
return isAuthenticated;
}
Look at what didn't change — the signature. Same params, same return value. The components using this hook don't need to change at all, because the contract is identical. That's the goal: write encapsulated code such that as long as the signature is the same, nothing upstream breaks.
One thing worth noticing though — should a hook really be the thing subscribing directly to eStore? Think about it. The hook's job is to bridge a data source to React state. The logic of how to subscribe to eStore, how to derive isAuthenticated from it — that's not a hook's concern. That deserves a service.
The Service Layer
A service is where raw, stateless business logic lives. It's a plain TypeScript class with no React in it. It handles subscriptions, transformations, and side effects — and exposes a clean interface to whoever calls it.
class UserService {
getUser() {
return eStore.store;
}
onDidChange(key, cb) {
return eStore.onDidChange(key, cb);
}
onAuthChange(cb) {
return UserService.onDidChange('isAuthenticated', cb);
}
}
Now the hook stops caring about eStore entirely:
function useIsAuthenticated() {
const [isAuthenticated, setIsAuthenticated] = useState(
() => Boolean(UserService.getUser()?.isAuthenticated)
);
useEffect(() => {
const unsubscribe = UserService.onAuthChange((newValue) => {
setIsAuthenticated(newValue);
});
return () => {
unsubscribe();
};
}, []);
return isAuthenticated;
}
The communication between eStore and React state is no longer the hook's responsibility. Some third party handles it, exposes a contract, and as long as that contract is guaranteed, everything is fine.
Now, what if you need to take this to the web?
The UserService above is tied to an Electron implementation. There is no eStore in a browser. On the web, suppose user information comes from an API — you hit an endpoint and it returns the user if they're authenticated.
So let's build this up properly. First, a low-level API wrapper:
// user-api.service.ts
class UserApiService {
constructor(private httpClient: HttpClient) {}
getUserData(): Promise<User | null> {
return this.httpClient.get('/user').send();
}
}
Then, a web-specific implementation that satisfies the same contract as the Electron version. This one polls the API and notifies subscribers whenever the value changes:
// user-web.service.ts
class UserWebService implements IUserService {
private authChangeCbs: Array<(isAuthenticated: boolean) => void> = [];
private pollInterval: ReturnType<typeof setInterval>;
constructor(private apiService: UserApiService) {
this.pollInterval = setInterval(() => {
this.apiService.getUserData().then(user => {
this.authChangeCbs.forEach(cb => cb(Boolean(user)));
});
}, 5000);
}
onAuthChange(cb: (isAuthenticated: boolean) => void): () => void {
const wrappedCb = (user: User | null) => cb(Boolean(user));
this.authChangeCbs.push(wrappedCb);
return () => {
this.authChangeCbs = this.authChangeCbs.filter(fn => fn !== wrappedCb);
};
}
destroy(): void {
clearInterval(this.pollInterval);
this.authChangeCbs = [];
}
}
And the Electron version looks like this:
// user-electron.service.ts
class UserElectronService implements IUserService {
private store = eStore;
onAuthChange(cb: (isAuthenticated: boolean) => void): () => void {
return this.store.onDidChange('isAuthenticated', cb);
}
destroy(): void {
this.store.clearSubscriptions();
}
}
Both implement IUserService. Both expose the same onAuthChange and destroy methods. Now you need a façade — a single UserService that the rest of the app talks to, which internally delegates to whichever implementation is active:
// user.service.ts
class _UserService {
private impl!: IUserService;
init(instance: IUserService): void {
this.impl = instance;
}
onAuthChange(cb: (isAuthenticated: boolean) => void): () => void {
return this.impl.onAuthChange(cb);
}
destroy(): void {
this.impl.destroy();
}
}
export const UserService = new _UserService();
Now the hook we wrote earlier doesn't need to change at all. Neither do any components. The only thing that changes is the entry point.
The entry point is where environment differences live
// app.web.tsx
const httpClient = new HttpClient({
beforeRequest() {
httpClient.setHeader('Authorization', `Bearer ${getToken()}`);
},
get(...args) {
return axios.get(...args);
},
});
const userApiService = new UserApiService(httpClient);
UserService.init(new UserWebService(userApiService));
function App() {
useEffect(() => {
return () => UserService.destroy();
}, []);
return <Router />;
}
// app.electron.tsx — notice this is the only file that changes
UserService.init(new UserElectronService());
function App() {
useEffect(() => {
return () => UserService.destroy();
}, []);
return <Router />;
}
Notice what happened. The HttpClient adapts to axios on the web — but tomorrow it could adapt to IPC for Electron, or some native HTTP client in Swift. One layer of networking done. Then UserWebService sits on top of that, abstracting the polling mechanism. Then UserService sits on top of that, abstracting which implementation is even in play. Each layer holds one responsibility and performs it well. That's what makes each layer reusable regardless of environment.
Services are stateless, not stateful
One more thing to understand about services: they should work like an input/output layer as much as possible. A service should only hold data that is directly its concern.
What it must never do is dispatch state updates, fire React events, or trigger re-renders. That's the hook's job. Take logout as an example:
UserService.logout() can delete the user instance, clear the token, reset any internal flags — everything that is pure JavaScript.
But the React side of logout — managing loading state, updating component trees — that belongs in the hook:
function useLogout() {
const [isLoading, setIsLoading] = useState(false);
const logout = async () => {
if (isLoading) return;
setIsLoading(true);
try {
await UserService.logout();
userState.setUser(null);
} finally {
setIsLoading(false);
}
};
return { logout, isLoading };
}
The service does the work. The hook reacts to it. Clear separation.
The UI Layer
The last layer is purely presentational. Components here are driven by props, they render UI, and they call callbacks. They have no idea where their data came from.
The goal is to make your components generic enough that they have no business bindings — or at least the fewest possible. This is where a lot of mistakes happen.
Take this example: isHistoryView as a prop on a dropdown item. That prop leaks business meaning into a generic component. The dropdown doesn't need to know what a "history view" is. What it needs to know is how to render itself — so the prop should be something like isIndented, which describes the visual treatment, not the reason for it.
// ❌ the component now knows about your product's concept of a history view
<DropdownItem isHistoryView />
// ✅ the component just knows it should indent its contents
<DropdownItem isIndented />
Alongside that, your components should be broken into small, composable pieces. The goal is to have enough building blocks that you can assemble any variation of the component without touching its internals.
<DropdownMenu>
<DropdownHeader label="Recent" />
<DropdownItem isIndented label="Item A" />
<DropdownItem isIndented label="Item B" />
<DropdownDivider />
<DropdownItem label="Settings" />
</DropdownMenu>
The practical pattern to achieve this is the container/presentational split. One component connects to hooks and collects data. Another component just takes props and renders.
// UserMenuContainer knows about hooks — this is where data lives
function UserMenuContainer() {
const isAuthenticated = useIsAuthenticated();
const { logout } = useLogout();
return <UserMenu isAuthenticated={isAuthenticated} onLogout={logout} />;
}
// UserMenu knows nothing about hooks, services, or stores
function UserMenu({ isAuthenticated, onLogout }: UserMenuProps) {
if (!isAuthenticated) return <LoginButton />;
return <button onClick={onLogout}>Log out</button>;
}
This split isn't always a perfect science — finding the right level of composability takes practice and won't always be obvious on the first pass. But the direction is clear: the further down the tree, the more generic and prop-driven your components should be. Design system components are a good reference point for what this looks like when done well.
Putting it together
Your raw JavaScript logic goes in services. Hooks consume those services and make them usable in React. Components consume those hooks and render UI.
As long as each layer's contract holds, everything above it is safe from change. That's what lets you swap Electron for the web, Zustand for an API, or one HTTP client for another — without touching a single component.


