Route Oriented Architecture for organizing React components

What is ROA? 

Route Oriented Architecture or ROA helps you organize your project by grouping all the components that are responsible for rendering views together instead of having a flat structure grouped by the type of the component. For example, when you visit user management page in the admin panel (eg. “/admin/user-management”), you might have some components that are used only in the admin panel, like Navigation and when you are deeper in user management it can have a Table with users, user Preview and some Controls to open edit form or delete prompt.

Every application has some application-wide reusable components like Buttons, Inputs, etc. these parts would be placed on the top level, for easier access.  Those shared components are under the general name “Components” in the image above.


You should already notice a pattern there. APP, Admin, and User Management are the Routable Components and each can have its Components or another Routes. This way your file structure will reflect the URL structure (eg. “/user-management/profile/edit/”). Good news is -it doesn't have depth limitation. It will be easier for you to find related components in case you want to edit them or simply add some code. And it will be easy to change the structure if you want to change the URL,, all you have to do is move the Route folder to a different directory.

Smart / Dumb Components

The Routable Components are responsible for more than just routing. Handling interactions and Store connection is another thing (later about that). This makes Routable Components considered as Smart Components because they are aware of the whole Application and they are responsible for doing multiple things. While components that are used within Smart Components are called Dumb Components because they are only responsible for presentation. Dumb Components are taking the Data and sending the Actions to their parent. They should be isolated and pure. What're the pure components? It always returns the same value for the same given arguments and it doesn’t have any side effects (no variables mutation, no accessing variables outside of the scope of the component). This way they’re much easier to test- you pass some data, simulate interaction and expect to receive the action with correct parameters. It also makes them more replaceable and reusable. Smart Components should not have another Smart Component as a child, because this creates confusion, where you should pass the data. The only exception to that rule is when you have child Route, like on the diagram above. But you wouldn't exchange data nor actions between them directly.

Example of Dumb Component:

interface PropsInterface {
 textSubmitHandler: (text: string) => ChatSubmitTextAction;
}

const Editor: React.FC<PropsInterface> = ({ textSubmitHandler }) => {
 const [text, setText] = useState('');

 return (
   <form
     className="editor"
     onSubmit={(event) => {
       const trimmedText = text.trim();

       event.preventDefault();

       if (trimmedText.length > 0) {
         textSubmitHandler(trimmedText);
         setText('');
       }
     }}
   >
     <textarea
       className="editor__textarea"
       onChange={(event) => setText(event.target.value)}
       value={text}
     ></textarea>
     <button
       className="editor__icon"
       type="submit"
     >
       <FontAwesomeIcon icon={faPaperPlane} />
     </button>
   </form>
 );
}

export default Editor;

Example of Smart Component:

interface PropsInterface {
messages: MessageInterface[];
users: UserInterface[];
myUserId: string | null;
submitTextAction: (text: string) => ChatSubmitTextAction;
messageRenderedAction: () => Action;
deleteMessageAction: (id: string) => ChatDeleteMessageAction;
editMessageAction: (id: string, text: string) => ChatEditMessageAction;
}

const Chat: React.FC<PropsInterface> = ({
messages,
myUserId,
users,
submitTextAction,
messageRenderedAction,
deleteMessageAction,
editMessageAction,
}) => {
return (
<div className="chat">
<div className="chat__messages">
{messages.map((message) =>
<div
className="chat__messages-item"
key={message.id}
>
<Message
message={message}
users={users}
actionsAllowed={message.userId === myUserId}
initHandler={messageRenderedAction}
deleteHandler={deleteMessageAction}
editHandler={editMessageAction}
/>
</div>
)}
</div>
<div className="chat__editor">
<Editor textSubmitHandler={submitTextAction} />
</div>
</div>
);
}

const mapStateToProps = (state: AppStateInterface) => ({
messages: getMessages(state),
myUserId: getMyUserId(state),
users: getAllUsers(state),
});

const mapDispatchToProps = (dispatch: Dispatch) => ({
submitTextAction: (text: string) => dispatch(chatSubmitText(text)),
messageRenderedAction: () => dispatch(chatMessageRendered()),
deleteMessageAction: (id: string) => dispatch(chatDeleteMessage(id)),
editMessageAction: (id: string, text: string) => dispatch(chatEditMessage(id, text)),
});

export default connect(mapStateToProps, mapDispatchToProps)(Chat);

The Redux Store

We use Redux for a few reasons. It gives us one source of data (single source of truth pattern), which helps with:

  • debugging,
  • modifying in one place to populate changes everywhere,
  • keeping everything in sync,
  • tracking the data and how it changes. 

All changes to the Store are done by Actions, which again helps to trace what is happening in the App. We use Reducers to save the data and Selectors to retrieve them. 


This is how it looks in the project structure:

Actions are plain objects, but what if we want to respond to Action with some other functions like making API requests? We can use Sagas. Sagas are just like Reducers, they are responding to selected Actions, but Sagas can be asynchronous and can dispatch another Actions. The popular set of Actions is: SUBMIT_FORM_ACTION → POST_REQUEST → POST_SUCCESS / POST_FAILURE. You might create another Saga that will wait for POST_SUCCESS and would dispatch CLOSE_MODAL Action, etc. The possibilities are endless.

Example of Saga:

function* saveUser() {
yield takeEvery(USER_FORM_SUBMITTED, function* (action: SaveUserAction) {
yield put(saveUserPostRequest());

try {
const result = call(requestFunctionPlaceholder, action.data);
yield put(saveUserPostSuccess(result));
} catch (error) {
yield put(saveUserPostFailure());
}
});
}

const prefix = '[CHAT]';

export const CHAT_SUBMIT_TEXT = `${prefix} SUBMIT_TEXT`;
export const CHAT_MESSAGE_RENDERED = `${prefix} MESSAGE_RENDERED`;

export interface ChatSubmitTextAction extends Action {
text: string;
}

export const chatSubmitText = (text: string): ChatSubmitTextAction => ({
type: CHAT_SUBMIT_TEXT,
text,
});

export const chatMessageRendered = (): Action => ({
type: CHAT_MESSAGE_RENDERED,
});

Example of Reducer:

export interface ChatReducerInterface {
messages: MessageInterface[];
}

const defaultState: ChatReducerInterface = {
messages: [],
};

export default (state = defaultState, action: Action): ChatReducerInterface => {
switch (action.type) {
case CHAT_SAVE_MESSAGES: {
return {
...state,
messages: [...(action as ChatSaveMessagesAction).messages]
};
}
default: {
return state;
}
}
};

Example of Selector:

import { AppStateInterface } from '../reducers/index';

export const getAllUsers = (state: AppStateInterface) => state.users.data;

export const getActiveUsers = (state: AppStateInterface) =>
state.users.data.filter(user => user.active);

export const getMyUserId = (state: AppStateInterface) => state.users.myUserId;

Scalability

Here are some other guidelines that will help you organise your project:

  • Keep It Simple, try to have most of your components Dumb not Smart. As you already know Dumb components are small, simple and easy to test. If you want to add some more complex logic into Dumb Component, think first if this cannot be moved into Smart Component.
  • Actions should tell you what happened not what will happen, in other words, use USER_FORM_SUBMIT_ACTION instead of SEND_USER_FORM_DATA_ACTION. this helps you remember that Store is the one to decide what will happen in your Application. The form could be submitted, but the store decides not to send the data, but maybe to show some error instead. It is also easier to debug, you open Redux-DevTools Extension in your browser and you see that: something was clicked, a request was made, data was retrieved, etc. In the screenshot above, Store decided to saveUser, because USER_FORM_SUBMITTED Action was dispatched.
  • Save the backend data as you receive it, use Selectors to remap / filter it. Like in the example above, once you have the data saved in reducer, you can return it raw, modified, filtered or in any shape you want.
  • Reducers should be immutable, always return a new state, this has a performance impact and secures you from unwanted changes. Also, it might not trigger re-render if the reference to the object is the same.

Like what our developers do? Join our frontend team, we’re looking for software developers - check our job offers!  

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .