git push and zero CI configuration — because there is nothing to build. This post covers how that deployment model works, then dives into three client-facing features that live inside the five-file no-build architecture: an interactive project cost estimator built with useState, a tabbed AI showcase using useReducer, and a dismissible newsletter banner that stores its dismissed state in sessionStorage. All three use only React's built-in hooks. No Redux, no Zustand, no Formik, no animation library.
Where the Previous Post Left Off
The last COLEwebdev.org post established the foundation: React 18 and Babel Standalone loaded from CDN, five JSX files transpiled in-browser, and a development workflow that starts and ends with python -m http.server 8080. That post focused on the architecture. This one focuses on what that architecture makes possible once you deploy it — and on three specific components that demonstrate what React hooks look like when there is no bundler, no tree-shaking, and no module system other than sequential script loading.
The repo has crossed 230 commits since that post. The site continues to be actively maintained for a real Cape Cod web design studio. Understanding how its interactive features are built is useful both for the specific techniques and for what they say about the no-build tradeoff at scale.
The Deployment Story: Why GitHub Pages Is the Ideal Host
Most React apps require a build step before deployment. The output of npm run build is a directory of static assets — HTML, minified JS bundles, chunked CSS files — that a host like Netlify or Vercel serves. The build pipeline is a prerequisite, not an option.
COLEwebdev.org sidesteps this entirely. Its source files are its deployment artifacts. The five JSX files, styles.css, and index.html that a developer edits locally are the exact files the browser downloads in production. There is no compilation, no bundling, no output directory. This makes GitHub Pages the natural choice.
GitHub Pages serves a repository's files directly from a branch — typically main or a designated gh-pages branch — over HTTPS with a CDN layer. For a conventional React app, you'd write a GitHub Actions workflow that runs npm ci && npm run build, then deploys the dist/ folder. For COLEwebdev.org, that workflow doesn't exist:
# .github/workflows/ — this directory doesn't exist in the repo.
# There is no CI pipeline because there is nothing to build.
#
# Deployment is:
# git add .
# git commit -m "update services section"
# git push origin main
# GitHub Pages picks up the push and serves the updated files
# within about 30 seconds.
This isn't a simplification of the deployment process — it's the elimination of it. A content update to the services section, a new portfolio item, a revised testimonial: all go live with a git push. There's no deployment queue to wait in, no build to fail, no artifact to debug. The feedback loop from commit to live site is measured in seconds.
The one configuration required is a custom domain. GitHub Pages supports custom domains via a CNAME file in the repository root and a DNS record pointing to GitHub's servers. Once that's set, the domain just works — no environment variable, no build-time injection, no CDN purge step.
The Interactive Project Cost Estimator
The most conversion-critical feature on any web agency site is the one that answers "how much does this cost?" before the prospect has to ask. COLEwebdev.org's <Estimator /> component, defined inside parts-services.jsx, handles this with a multi-step form that calculates a project cost range in real time.
The component manages its state entirely with useState. A single state object holds the current selections:
const [config, setConfig] = React.useState({
projectType: 'brochure', // 'brochure' | 'ecommerce' | 'webapp'
pageCount: 'small', // 'small' | 'medium' | 'large'
features: {
cms: false,
seo: false,
forms: false,
auth: false,
api: false,
},
timeline: 'standard', // 'standard' | 'rush'
});
Each selection maps to a cost modifier in a lookup table. The project type sets a base range, page count scales it, each feature toggle adds a fixed increment, and the rush timeline multiplier applies last:
const BASE = {
brochure: [1200, 2800],
ecommerce: [3500, 7500],
webapp: [6000, 18000],
};
const PAGE_SCALE = { small: 1.0, medium: 1.35, large: 1.75 };
const FEATURE_ADD = { cms: 600, seo: 350, forms: 400, auth: 800, api: 1200 };
const RUSH_MULT = { standard: 1.0, rush: 1.3 };
function calcRange(cfg) {
const [lo, hi] = BASE[cfg.projectType];
const scale = PAGE_SCALE[cfg.pageCount];
const extras = Object.entries(cfg.features)
.filter(([, on]) => on)
.reduce((sum, [k]) => sum + FEATURE_ADD[k], 0);
const mult = RUSH_MULT[cfg.timeline];
return [
Math.round((lo * scale + extras) * mult / 100) * 100,
Math.round((hi * scale + extras) * mult / 100) * 100,
];
}
Every time a selection changes, React re-renders and the displayed range updates immediately. There's no debounce, no async fetch, no server-side pricing logic — the entire calculation runs synchronously in the browser. The output is a range rather than a fixed number, which is honest about the estimate's precision and avoids the commitment anxiety that a hard quote triggers.
The Contact Integration: Mailto as a Data Bridge
The estimator closes with a "Get a Precise Quote" button. Clicking it doesn't open a modal or submit a form to a backend — it opens the user's email client with a pre-filled message that includes their exact configuration:
function buildMailto(cfg, range) {
const features = Object.entries(cfg.features)
.filter(([, on]) => on)
.map(([k]) => k)
.join(', ') || 'none selected';
const body = [
`Project type: ${cfg.projectType}`,
`Page count: ${cfg.pageCount}`,
`Features: ${features}`,
`Timeline: ${cfg.timeline}`,
`Estimated range: $${range[0].toLocaleString()} – $${range[1].toLocaleString()}`,
'',
'Additional details:',
].join('%0D%0A');
return `mailto:hello@colewebdev.org?subject=Project%20Quote%20Request&body=${body}`;
}
The mailto approach eliminates backend infrastructure entirely. No contact form API, no email service integration, no spam filtering layer, no rate limiting. The email lands in the studio's inbox looking like a pre-filled quote form that the prospect submitted. From the prospect's perspective, they clicked a button and their email client opened with their project details already filled in. The friction between "interested" and "in contact" is one click.
The tradeoff is that the email client must be configured and accessible. For mobile users — which represents the majority of marketing site traffic — this is almost always true. For users in locked-down enterprise environments, a fallback copy-to-clipboard button displays if the mailto link fails to open within 500ms. The detection is imperfect, but the fallback is better than nothing.
The AI Showcase: useReducer for Tab State
The <AIShowcase /> component, also in parts-services.jsx, presents a tabbed interface that demonstrates AI-assisted development workflows available to COLEwebdev clients. Each tab shows a before/after example: what the task looked like before AI tooling, and what the outcome looked like after.
Tab state uses useReducer rather than useState because the component has two interacting state dimensions — which tab is active and whether the before/after toggle is in its "after" position — and update logic that depends on both:
const initialState = { activeTab: 0, showAfter: false };
function reducer(state, action) {
switch (action.type) {
case 'SELECT_TAB':
return { activeTab: action.index, showAfter: false };
case 'TOGGLE_VIEW':
return { ...state, showAfter: !state.showAfter };
default:
return state;
}
}
function AIShowcase() {
const [state, dispatch] = React.useReducer(reducer, initialState);
// ...
}
Switching tabs resets the before/after toggle to "before" — that's encoded in the SELECT_TAB case. If each dimension were a separate useState, you'd need a useEffect to reset the toggle when the tab changes, which introduces a render cycle and a potential stale-closure bug. The reducer makes the relationship between the two dimensions explicit and co-located.
The tab transition animation uses CSS alone — a combination of opacity, transform: translateY(), and a class toggled by the active tab index. No animation library, no requestAnimationFrame, no JavaScript-driven interpolation. The browser handles easing, compositing, and GPU acceleration automatically:
.showcase-panel {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.25s ease, transform 0.25s ease;
pointer-events: none;
}
.showcase-panel.active {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
The before/after panels within each tab use the same pattern — a CSS class driven by the showAfter state value. The transition gives users a clear visual signal that the view has changed without the motion being distracting. The prefers-reduced-motion media query suppresses both transitions for users who have configured that preference:
@media (prefers-reduced-motion: reduce) {
.showcase-panel,
.showcase-before-after {
transition: none;
}
}
The Newsletter Banner: sessionStorage as the State Backend
The <NewsletterBanner /> component renders as a sticky bar at the bottom of the viewport. It asks for an email address and offers a dismissal option. The challenge is persistence: once dismissed, it should stay dismissed for the session — but not permanently. If the user comes back tomorrow, showing it again is reasonable. If they dismissed it two minutes ago, showing it again is obnoxious.
sessionStorage is the right scope. It persists across page refreshes within a browser tab but clears when the tab is closed. There's no server-side session, no cookie, no localStorage key that persists indefinitely. The banner uses it with a single flag:
function NewsletterBanner() {
const [visible, setVisible] = React.useState(() => {
return sessionStorage.getItem('newsletter-dismissed') !== 'true';
});
function dismiss() {
sessionStorage.setItem('newsletter-dismissed', 'true');
setVisible(false);
}
if (!visible) return null;
return (
<div className="newsletter-banner" role="complementary" aria-label="Newsletter signup">
<p>Cape Cod web tips — monthly, no spam.</p>
<form onSubmit={handleSubmit}>
<input type="email" placeholder="you@example.com" required />
<button type="submit">Subscribe</button>
</form>
<button onClick={dismiss} aria-label="Dismiss newsletter banner" className="banner-close">✕</button>
</div>
);
}
The lazy initializer in useState — the function form () => sessionStorage.getItem(...) — is important. It reads from sessionStorage once, at mount time, not on every render. The regular form useState(sessionStorage.getItem(...)) would call sessionStorage.getItem on every render, which is a minor performance issue but more importantly suggests the wrong intent. The lazy initializer makes it explicit: we're initializing from storage, not reading from it reactively.
The component returns null rather than rendering a hidden element when dismissed. This matters for accessibility: a hidden element with display: none is still in the DOM and still carries its role and aria-label attributes, which screen readers may still discover. A null return removes the component from the DOM entirely, which is cleaner and avoids any possibility of a hidden focusable element causing tab-order confusion.
State Management Without a State Management Library
All three components share a pattern worth naming explicitly: they use React's built-in hooks and nothing else. No Redux store, no Zustand atom, no Context provider. Each component owns its own state and the components don't share state with each other.
This is the right call for this application's complexity profile. The estimator's state is local to the estimator. The AI showcase's tab state is local to the showcase. The newsletter's dismissed flag is local to the banner. There is no state that needs to be shared across components, no user authentication state to persist globally, no API response cache that multiple components need to read from the same source.
When the answer to "where does this state live?" is always "in the component that renders it," you don't need a state management library. Adding one would solve a problem that doesn't exist in exchange for dependency weight, API surface to learn, and a build pipeline to support it. The no-build architecture makes the choice explicit rather than default: every dependency has to justify its presence, because every dependency is a CDN script tag with a version number you're committing to maintain.
What 230+ Commits on a No-Build App Looks Like
The COLEwebdev.org repository has grown steadily since its first commit. A history that reaches 230+ commits on a marketing site with no build pipeline says something about the workflow it enables. Most of those commits are content updates — revised copy, new portfolio items, updated testimonials, pricing adjustments — that required no developer tooling to make and deploy.
The five-file sequential loading architecture has remained stable across all those commits. New components get added to the appropriate file; the load order doesn't change. The CSS variables in styles.css have been updated — the paper color went from #F8FAFB to #EEFAF8 at some point, introducing a green-blue tint that feels warmer and more distinctive on mobile screens. The Schema.org structured data has been updated to reflect current review counts. The estimator pricing table has been revised as the studio's actual project costs have shifted.
None of those changes required touching a build configuration, updating a lockfile, or running a test suite. They required editing the relevant file and pushing. The architecture's sustainability advantage compounds over time: the maintenance surface doesn't grow with the commit count, because there's nothing to maintain except the application code itself.
When to Apply These Patterns
The interactive estimator, AI showcase, and newsletter banner patterns are portable. They work in any React project, with or without a build step. The specific techniques worth taking away:
- Pricing calculators belong in the browser. A synchronous lookup table with
useStateis faster, cheaper, and more reliable than a backend pricing API for any configuration space that fits comfortably in a JS object. - Tabbed interfaces with cross-tab side effects (like resetting a nested toggle when switching tabs) are a natural fit for
useReducer. The reducer makes the relationship between state dimensions explicit and testable. - Session-scoped UI state (banners, onboarding checklists, one-time prompts) belongs in
sessionStorage, notlocalStorage. It persists long enough to avoid re-showing within a session, clears when the user is done, and requires no expiry logic. - CSS transitions over JS animation for UI element enter/exit. Anything that can be expressed as a class toggle should be — the browser composites it off the main thread and it degrades gracefully via
prefers-reduced-motion.
The COLEwebdev.org codebase is a useful reference for how far React's built-in toolkit stretches before you need external dependencies. The answer, for a marketing site with interactive features, is: quite far. The repo is at github.com/josefresco/COLEwebdev.org. The patterns are in parts-services.jsx and app.jsx.