I had an e-commerce project — plain HTML/JavaScript frontend, Node.js/Express backend. I took on a more interesting challenge: migrating the frontend from vanilla HTML/JS to React.
Migrating code you already wrote and understand is one of the better ways to go deeper with React. You're not learning the syntax and the concepts at the same time — you already know what the code does, so you can focus entirely on why each React pattern exists. This is what that looked like.
The original version
Each page was a separate HTML file loaded its own JS modules. State lived in the DOM. Navigation caused full page reloads. The patterns were familiar:
-
updateCartIcon()fetches the cart total from the backend and updates the cart icon in the banner — it had to be manually called in every place that could affect the cart count -
showHideMenuItems()checks if the user is logged in and toggles the visibility of login, signup, and logout buttons by directly settingelement.style.display - Any change to the shared header or footer meant touching every HTML file
None of these are bugs, but they're all symptoms of the same root problem: state is scattered and has to be manually synchronized.
Pages → components
The mapping is direct. Each HTML file becomes a page component, and React Router replaces file-based navigation. window.location.href = '/cart.html' becomes navigate('/cart'). <a href="/login.html"> becomes <Link to="/login">.
The more meaningful difference is that only the page component swaps out on navigation — the rest of the component tree stays mounted, so state persists across the session without any extra work.
Extracting shared UI
Every page shared the same banner, header, and footer. In the old app this was solved with copy-paste. In React, a Layout component wraps all routes via React Router's <Outlet />:
Layout
├── Banner
├── Header
├── <Outlet /> ← current page renders here
└── Footer
The Banner itself had three distinct responsibilities — a mobile menu toggle, auth-aware navigation links, and a cart icon — so it was broken into Banner, Navigation, and CartIcon components.
The toggle was the first real state decision: the button lives in Banner, and Navigation receives isOpen as a prop. The state lives in the closest common ancestor, which is the standard lift-state-up pattern. Straightforward.
The custom hook trap
The cart count was where things got more interesting.
CartIcon in the banner needs to display the total items the user added to the cart. ProductList needs to refresh that total after adding an item. Two components, one shared value — the obvious move is a custom hook that fetches the total from the backend and exposes a refresh function to re-fetch on demand:
function useCartCount() {
const [cartCount, setCartCount] = useState(0);
const refresh = async () => {
/* fetch /api/cart/cart-count */
};
useEffect(() => {
refresh();
}, []);
return [cartCount, refresh];
}
If you call this hook in both components, it looks clean. It doesn't work.
The issue is obvious in hindsight: each hook call is an independent instance. CartIcon and ProductList each had their own isolated cartCount state. When ProductList called refresh(), it updated its own copy. CartIcon never knew anything changed.
Extracting stateful logic into a custom hook does not automatically make that state shared.
Context as the fix
The fix was to move ownership of the cart count into a provider above both components:
CartContextProvider
├── Banner
│ └── CartIcon ← reads cartCount
└── Home
└── ProductList ← calls refresh()
There are two useful hook roles here:
- A private hook can manage the provider’s state and fetching logic.
- A public hook can read the values shared through context.
The custom hook still exists — but now it runs inside CartContextProvider, whenever the provider renders, but its state persists across those renders. Everything downstream reads from that one instance via useContext. When ProductList calls refresh(), it's the same function CartIcon is subscribed to.
I could also keep the state and fetching logic directly inside CartProvider. Extracting that implementation into a private hook is optional.
The same pattern applied to auth. Navigation needs isLoggedIn to render the right links. CartIcon needs it to decide whether to render at all. Pulling both into an AuthContext replaced the old showHideMenuItems() with declarative rendering — components just read state and React handles the rest.
Where login logic lives — and why it matters
The first version had the login fetch inside the Login component. It sent the request, and on success, called navigate('/').
The problem: the Login component had no connection to AuthContext. After a successful login, isLoggedIn in the context hadn't changed. The banner still showed "Log in". The app looked broken until you refreshed.
The fix is to move login() and logout() out of the component and into AuthContext — so they have direct access to setUser and can update the shared state immediately on success.
This also revealed a cleaner separation of concerns. useAuth should only do one thing: check the session on mount and expose the user state. The actions — login, logout — belong in the context that owns that state:
-
useAuth— runscheckAuthon mount, returns{ user, setUser } -
AuthContext— defineslogin()andlogout(), usessetUserfrom the hook to update state, provides everything to the tree -
Login- handles the form, error messages, button disabled state, loading indicators, and navigation after success
This settled into a useful pattern for the app: the provider manages shared auth, the hook provides access, the component owns UI — error messages, button disabled state, loading indicators. The Login component now just calls login() through useAuth() and reacts to the response.
Session cookies vs localStorage
There are two common ways to handle auth persistence on the frontend.
JWT + localStorage — on login, the backend returns a token. The frontend stores it in localStorage and manually attaches it to every request as an Authorization header. It persists across refreshes, but the token is exposed to JavaScript, making it vulnerable to XSS attacks.
Cookie-based auth — on login, the backend sets a cookie containing a session identifier or token. The browser stores it and automatically attaches it to requests that match the cookie's scope and browser policies. With HttpOnly, JavaScript can't directly read the cookie. HttpOnly prevents direct cookie theft through JavaScript, but it doesn't stop an XSS attack from making authenticated requests.Cookie-based auth also needs appropriate CSRF protection and cookie settings such as Secure and SameSite.
JWTs can also be stored in cookies. The token format and the storage mechanism are separate choices.
For same-origin requests, fetch includes cookies by default. If the frontend and backend are on different origins, the frontend needs credentials: 'include', and the backend needs the appropriate CORS configuration. This option tells the browser to include them, so the backend can read the session and know who you are.
This app uses an HttpOnly cookie for its session. The consequence for React state is that isLoggedIn doesn't survive a page refresh — it's just JavaScript in memory. On every mount, useAuth calls /api/auth/me. If the session cookie is still valid, the backend confirms the user and state is restored. If not, the user is logged out.
While that check is pending, auth is still loading. A failed network request is also different from the backend confirming that the session is invalid.
React state reflects auth. The backend validates it. The cookie carries the credential. Keeping those roles separate is the right mental model.
The actual takeaway
The migration made one thing very concrete: in my old app, UI updates were driven by manual DOM changes; in React, the UI was rendered from state..
In the old app, showHideMenuItems() existed because the DOM didn't know about login state — you had to reach in and update it manually every time something changed. In React, isLoggedIn changes once and everything that depends on it re-renders. The DOM reflects state, rather than being the thing you manage directly.
Vanilla JS can use explicit state as its source of truth too. React gave this app a consistent way to organize that relationship.
That shift — from imperative DOM manipulation to declarative state — is what every React pattern ultimately comes back to. Lifting state up, custom hooks, Context — each one is just a different answer to the same question: how far does this state need to reach?
Top comments (0)