Most React apps start simple and get complicated. State bleeds between components, auth checks end up duplicated across views, and API error handling becomes a patchwork of try-catch blocks scattered through every page. Sin Pluma’s frontend avoids most of that by making three structural decisions early: centralized Redux state, higher-order components for access control, and a single Axios instance that handles token refresh automatically. This post traces how each of those decisions plays out in practice.
Architecture goals
The frontend architecture prioritizes a few things above interface aesthetics.
Modular component design keeps UI elements reusable and independent. Components that hold minimal local state and pull from Redux are straightforward to test and relocate.
Predictable state management through Redux means every state transition is explicit and traceable. When something breaks, the Redux DevTools tell you exactly what action fired and what the state looked like before and after.
Clear API communication layers mean network logic never leaks into UI components. Axios calls live in action creators. Components dispatch actions and render state. The two concerns stay separated.
Separation between presentation and business logic makes both sides easier to change. Adding a feature means adding an action, a reducer case, and a component, not hunting through mixed logic.
Technology stack
The frontend runs on React with React Router for client-side navigation. Redux manages global state with reducers that define state transitions deterministically. Slate.js powers the rich text editor. Material UI supplies the component library. Axios handles all HTTP communication.
Slate was chosen over simpler editors for specific reasons. It exposes a full document tree representation, which is what the sentiment analysis integration needs. Rendering and editing behavior can be customized in detail. It integrates naturally with React’s state model. A basic <textarea> would have made the sentiment annotation feature essentially impossible to build cleanly.
Material UI reduces the need for custom CSS while maintaining a consistent design system. Centralizing style definitions through MUI’s theming keeps visual behavior predictable across the application.
Project structure
The frontend lives in the reacts/ directory and follows a feature-organized layout.
reacts/src/
├── common/ # Shared utilities: Api.js, Session.js
├── components/ # HOCs: requireAuth, noRequireAuth, DrawIfAuth, DrawIfNotAuth
├── containers/ # Page-level containers wired to Redux
├── pages/ # Route-level views
├── reducers/ # 13 combined Redux reducers
├── actions/ # Redux action creators
└── index.js # App entry point with store and router setup The common/ folder is the most important place to understand first. Everything else in the app depends on what lives there. Api.js defines the Axios instance and interceptors. Session.js handles JWT parsing and session hydration.
State management with Redux
Sin Pluma uses Redux with 13 combined reducers. Each one owns a narrow slice of state.
// reducers/index.js
const rootReducer = combineReducers({
auth, // JWT tokens, login status
user, // User profile data
notebooks, // List of notebooks
notebook, // Single notebook detail
pages, // List of pages in a notebook
page, // Single page content
genres, // Genre lookup list
readings, // Follow relationships
image, // Image upload state
sentiment, // Sentiment analysis results
editor, // Editor mode, draft state
ui, // Loading flags, error messages
session, // Parsed JWT claims
}); The state flow is predictable throughout the application. A user interaction triggers a Redux dispatch. An asynchronous action performs the API request. The backend response updates the store. React components re-render from the updated state. That cycle applies to every feature uniformly.
Actions represent domain events clearly: CREATE_NOTEBOOK, LOAD_PAGES, LOGIN_SUCCESS. The naming makes the history of any session readable in the DevTools.
Keeping each concern in its own reducer means components subscribe to exactly what they need. A reading list component doesn’t re-render when editor state changes. A genre dropdown doesn’t care about auth status.
HOC-based auth guards
Sin Pluma has four higher-order components for access control. Two control route access; two control element visibility.
requireAuth — Wraps a route; redirects to login if no valid session
noRequireAuth — Wraps a route; redirects to home if already logged in
DrawIfAuth — Renders children only when the user is authenticated
DrawIfNotAuth — Renders children only when the user is not authenticated The route guards work by checking the Redux auth slice on mount. If the condition fails, they redirect using React Router’s <Redirect>. Components never need to know whether a page is protected.
// Usage in route definitions
<Route path="/editor/:id" component={requireAuth(EditorPage)} />
<Route path="/login" component={noRequireAuth(LoginPage)} /> The visibility components solve a different problem. The “follow” button on a notebook detail page should only appear if the reader is logged in and isn’t the author. Rather than putting conditional logic inside the component itself, you wrap the button.
<DrawIfAuth>
<DrawIfNotAuth condition={isAuthor}>
<FollowButton notebookId={id} />
</DrawIfNotAuth>
</DrawIfAuth> This pattern keeps the rendering logic declarative. The components stay clean.
Session management
common/Session.js handles everything related to parsing and storing JWT tokens. It exposes three functions.
parseJwt(token) decodes the base64 payload from a JWT string without verifying the signature. It’s client-side only; the server does the real validation. The function extracts the user ID, username, and expiry timestamp from the claims.
loadSession() reads the access token from localStorage, parses it, and dispatches a Redux action that populates the session slice. The app calls this on startup so a page refresh doesn’t log the user out.
startSession(accessToken, refreshToken) stores both tokens in localStorage and calls loadSession() to update Redux state immediately. The login flow calls this right after a successful POST to /api/login.
Axios interceptors and the token refresh queue
common/Api.js creates a single Axios instance with the API base URL and attaches interceptors to both the request pipeline and the response pipeline.
The request interceptor adds the Authorization header automatically.
api.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) config.headers["Authorization"] = `Bearer ${token}`;
return config;
}); The response interceptor handles 401 errors. The tricky part is what happens when multiple requests fail simultaneously because the access token expired. Without coordination, each failed request would independently try to refresh, causing a race condition: the first refresh succeeds and invalidates the old refresh token, then the second refresh fails because that token is already used.
Sin Pluma solves this with a subscriber queue.
let isRefreshing = false;
let subscribers = [];
function subscribeTokenRefresh(callback) {
subscribers.push(callback);
}
function onRefreshed(token) {
subscribers.forEach((cb) => cb(token));
subscribers = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
if (response?.status !== 401) return Promise.reject(error);
if (response.data?.msg !== "Token has expired")
return Promise.reject(error);
if (!isRefreshing) {
isRefreshing = true;
const refreshToken = localStorage.getItem("refresh_token");
const res = await api.post(
"/refresh",
{},
{
headers: { Authorization: `Bearer ${refreshToken}` },
},
);
const newToken = res.data.access_token;
localStorage.setItem("access_token", newToken);
isRefreshing = false;
onRefreshed(newToken);
}
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
config.headers["Authorization"] = `Bearer ${token}`;
resolve(api(config));
});
});
},
); The first failed request sets isRefreshing = true and starts the refresh. Every subsequent failed request during that window adds itself to the subscribers array. Once the refresh completes, onRefreshed replays all queued requests with the new token. The user sees nothing.
Routing setup
Routes are defined in index.js using React Router v5. Protected routes wrap their components with requireAuth. Public-only routes use noRequireAuth. A handful of routes are open to anyone.
/ — Home (open)
/login — Login (noRequireAuth)
/register — Register (noRequireAuth)
/profile/:username — User profile (open)
/works — Browse works (open)
/works/:id — Work detail (open)
/editor/:id — Page editor (requireAuth)
/create — Create notebook (requireAuth)
/search — Search results (open)
/settings — User settings (requireAuth) The router wraps the Redux <Provider> at the top level, so every component in the tree has access to the store.
Page composition
The pages layer contains high-level components mapped directly to application routes. Each page composes lower-level components and orchestrates data loading.
Authentication pages handle login, registration, and validation. They form-validate on the client, request authentication tokens, and update the Redux auth state on success.
The dashboard displays the user’s notebooks and acts as the primary entry point after login. Notebook data fetches from /api/notebooks on mount through an async Redux action. The component re-renders from the updated store once the response lands.
The notebook editor is the most complex page. It loads pages dynamically, manages Slate editor state, saves changes through debounced API requests, renders embedded images, and triggers linguistic analysis. It implements autosave with optimistic UI updates to keep writing responsive while maintaining data integrity.
The reader page presents published content in a simplified reading interface. It’s stateless, fetches content by identifier, and has no editing controls.
The search page sends a query input to /api/search?q=... and renders results dynamically as they arrive.
The Slate.js editor
The page editor runs in two modes depending on how the page loads.
In editor mode (the author’s view), Slate loads the page content as a JSON document, allows editing, and auto-saves on every keystroke through a debounced PUT request to /api/pages/{id}. The toolbar supports basic formatting: bold, italic, headings, and embedded images.
In reader mode, the same Slate component renders in read-only state. No cursor, no toolbar. The content displays exactly as the author wrote it.
The sentiment annotation layer sits on top of the editor. When the author clicks “Analizar”, the frontend traverses the Slate document tree to find text nodes, splits them into sentences, and sends each sentence to the linguistics service.
function getSentences(slateNodes) {
const sentences = [];
for (const node of slateNodes) {
if (node.text) {
node.text.split(/[.\n]/).forEach((s, i) => {
if (s.trim())
sentences.push({ key: `${node.key}-${i}`, text: s.trim() });
});
}
if (node.children) sentences.push(...getSentences(node.children));
}
return sentences;
} Each response from /lin/sentiment comes back as { key, label } where label is positive or negative. The editor stores these in Redux under the sentiment slice and uses Slate’s decorate function to apply color annotations to matching text ranges. Clicking “Analizar” a second time clears the sentiment slice and removes all decorations.
Image upload flow
Image uploads occur directly from the editor interface. The editor selects an image file, sends a multipart upload request to /api/notebooks/{id}/image/, and the backend stores the file in MinIO. The resulting object URL returns to the client, and the editor embeds it inside the Slate document structure as a custom element. This approach lets images behave as structured document nodes rather than raw HTML insertions.
Component layer and Material UI
Most components are intentionally lightweight. They either remain stateless or maintain minimal local state. Access to global data happens through Redux hooks: useSelector for reading state and useDispatch for triggering actions. This keeps UI elements reusable and independent from business logic.
Material UI supplies standardized components throughout: buttons, forms, dialogs, navigation bars, and cards. The library’s theming centralizes style definitions and keeps visual behavior consistent without custom CSS. Page containers connect to Redux via connect(mapStateToProps, mapDispatchToProps) and handle data fetching in componentDidMount. Display components receive props and don’t know anything about Redux or the API.
Architectural trade-offs
The current frontend architecture has a few constraints worth naming. Redux introduces boilerplate for smaller interactions that don’t really need global state. There’s no dedicated client-side caching layer like React Query, which means data fetches repeat on every remount. Server-side rendering is absent, which limits search engine indexing for public content. The application also lacks service workers or offline capabilities. These are sensible omissions for a platform focused on demonstrating engineering structure rather than production polish, but they’d be necessary steps for a real-world launch.
