React: Reusable, Decoupled, and Isolated
When working on a new page don’t just start writing components on the page itself. Start individualizing units of components so that each are independent enough and changes on the global state won’t cause a re-render. For example, imagine you were given the task to create this page.
You could be tempted, as I was when learning to code, to write all of these components in a single file.
// WRONG
export const LoginPage = () => {
const [userId, setUserId] = useState('')
const [password, setPassword] = useState('')
return (
<div>
<div className="d-flex flex-row">
<div id="marketing-container">
<svg {...fbLogo} />
<h2>Connect with friends and the world around you on Facebook</h2>
</div>
<div id="login-form">
<div>
<input id="user-id" value={userId} onChange={(e) => setUserId(e.target.value)} />
<input id="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button>Log in</button>
<a href="/forgot-password">Forgot password</a>
<br />
<button>Create new account</button>
</div>
<div>
<strong>Create a page</strong>
<span>for a celebrity, brand or business.</span>
</div>
</div>
</div>
</div>
)
}
export default LoginPage
But the reality is that a lot of these elements might be reused throughout the app in the future and that means they would need to be re-written or copy/pasted. When working with a well defined design, it is likely that elements will be used like legos, the logo shown on the log in page will be the same as the one on the dashboard screen, maybe just a different size of course. Same with the user id input, design wise it would probably be the same as the one on the user edit page.
Which brings me to the my next point, components should be decoupled from business logic to presentational logic. What I mean by this is that the part that communicates with the state should be its own component and this component would just pass presentational props to the presentational component.
// Presentational component
const UserIdInputComponent = ({ value, onChange }) =>
<input value={value} onChange={onChange} />
// Logic component
export const UserIdInput = () => {
const [value, setValue] = useState('')
const handleChange = (e) => setValue(e.target.value)
return <UserIdInputComponent value={value} onChange={handleChange} />
}
This enables tools such as storybook to work properly by only exporting presentational component decoupled from state management. It can be annoying having to integrate a logic heavy component on storybook, one that makes api calls, makes global state changes. With this approach you can see how a component will change visually based on different props.
Back to the main page. You can probably see where I am going with this. Instead of writing everything on the same page. Think of how this component can be reused, how it can be decoupled from state and how it can be isolated so that it never re-renders unless the props that are related to this component change.
export const LoginPage = () => (
<div>
<div className="d-flex flex-row">
<FbMarketing />
<LoginForm />
</div>
</div>
)
export default LoginPage
Best case scenario this is how you start coding it. It would be more cumbersome to come back and refactor it once you see that it all works as expected. I am guilty of wanting to see something quick on the screen, calm the anxiety and start building the proper structure from the beginning.
const FbLogo = () => (
<svg {...fbLogoAttributes} />
)
const FbSlogan = () => (
<h2>Connect with friends and the world around you on Facebook.</h2>
)
const FbMarketing = () => (
<>
<FbLogo />
<FbSlogan />
</>
)
Here is all presentational at this point. These can be further individualized as FbLogoSmall, FbLogoMedium, and such.
Now the part that contains some logic, the log in form. Not sure if it’s login, log-in, or log in but we will go ahead and use Facebook’s terminology, ‘log in’.
As a reminder, each component should be reusable, decoupled and isolated.
Reusable:
First lets make the UserIdInput reusable and then copy this approach onto the other password input: It’s worth noting that production level inputs will include other attributes such as test id, classes that change based on props, aria attributes, autofocus, so many more other props/attributes depending on the tools the codebase uses. If someone tells you it’s more complex than what I am writing here, listen to that person.
// UserIdInput.js
import { useContext, createContext } from "react";
export const UserIdContext = createContext();
const UserIdInput = () => {
const { userId, setUserId } = useContext(UserIdContext);
const handleChange = (e) => {
setUserId(e.target.value);
};
return <UserIdInputComponent value={userId} onChange={handleChange} />;
};
Now this input can be reused on the user edit form for example. This is how the password input would look:
// PasswordInput.js
import { useContext, createContext } from "react";
export const PasswordContext = createContext();
const PasswordInput = () => {
const { password, setPassword } = useContext(PasswordContext);
const handleChange = (e) => {
setPassword(e.target.value);
};
return (
<div>
<PasswordInputComponent value={password} onChange={handleChange} />
</div>
);
};
Decoupled:
Decoupled in the sense of decoupling its logic from its presentational part, ‘business logic’ vs visual: Here we can see that props are passed with no modifications or new function definitions in the middle, heck I’m even returning the jsx straight up with no return keyword. Again, if someone tells you it’s more complicated than this, it is… label
‘s should be their own components, input
‘s also.
// UserIdInputComponent.js
const UserIdInputComponent = ({ value, onChange }) => (
<div>
<label>User Id:</label>
<input type="text" value={value} onChange={onChange} required />
</div>
);
// PasswordInputComponent.js
const PasswordInputComponent = ({ value, onChange }) => (
<div>
<label>Password:</label>
<input type="password" value={value} onChange={onChange} required />
</div>
);
Isolated:
We have already took care of the isolated part by creating a context, now whenever we change any of the inputs, the other input would not be re-rendered. The only element that will be re-rendered are the input being changed and the log in button. This is a good indicator of wether your react app is properly optimized, premature optimization sometimes is good. Level up the team.
const LoginButton = () => {
const { userId } = useContext(UserIdContext);
const { password } = useContext(PasswordContext);
const onClick = (e) => {
e.preventDefault();
console.log("form submit", userId, password)
};
return <button onClick={onClick}>Log in</button>;
};
Except! This actually did not happen, I tried using context to isolate the changes but when it came down to sharing userId
and password
I had to use redux because as soon as I used UserIdProvider to wrap the LoginButton
it created a new state with new userId
and password
. This is how it looks with redux.
// LoginButton.js
import { useSelector } from "react-redux";
const LoginButton = () => {
const { userId, password } = useSelector(state => state)
const onClick = (e) => {
e.preventDefault();
console.log("form submit", userId, password);
};
return <button onClick={onClick}>Log in</button>;
};
export default LoginButton
Probably should have typed it before but here is the redux store.
// store.js
import { createSlice, configureStore } from '@reduxjs/toolkit'
const login = createSlice({
name: 'login',
initialState: {
userId: '',
password: '',
},
reducers: {
userId: (state, action) => {
state.userId = action.payload
},
password: (state, action) => {
state.password = action.payload
}
}
})
export const { userId: setUserId, password: setPassword } = login.actions
export const store = configureStore({
reducer: login.reducer
})
Dating myself with redux but it just works beautifully to isolate changes so that it minimizes re-renders. Usually I wouldn’t trust much someone who avoid re-renders at all cost but that’s just a good indication of good react code.
Here are the updated files for the two inputs. Not a lot changed but pay attention to how easy it was for me to change only the business logic component. Changed the value selector, the handleChange function and that was it. This is one of the advantages of decoupling, it’s not that obvious with such a small component but a codebase that uses complex logic I can see how this approach can be beneficial.
// UserIdInput.js (revised final)
import { setUserId } from "./store";
import { useDispatch, useSelector } from "react-redux";
const UserIdInputComponent = ({ value, onChange }) => (
<div>
<label>User Id:</label>
<input type="text" value={value} onChange={onChange} required />
</div>
);
const UserIdInput = () => {
const userId = useSelector(({ userId }) => userId)
const dispatch = useDispatch()
const handleChange = (e) => {
dispatch(setUserId(e.target.value))
};
return <UserIdInputComponent value={userId} onChange={handleChange} />;
};
// PasswordInput.js (revised final)
import { useDispatch, useSelector } from "react-redux";
import { setPassword } from "./store";
const PasswordInputComponent = ({ value, onChange }) => (
<>
<label>Password:</label>
<input type="password" value={value} onChange={onChange} required />
</>
);
const PasswordInput = () => {
const password = useSelector(({ password }) => password)
const dispatch = useDispatch()
const handleChange = e => {
dispatch(setPassword(e.target.value))
};
return <PasswordInputComponent value={password} onChange={handleChange} />
};
The result should only highlight updates on the changed input and the login button itself like so:
There’s a problem though, the labels are also updating. Let’s fix that really quick just to prove the point of over, but potentially necessary optimization. Up to your discretion.
// UserIdInput.js
import { setUserId } from "./store";
import { useDispatch, useSelector } from "react-redux";
const UserIdInputComponent = ({ value, onChange }) => (
<input type="text" value={value} onChange={onChange} required />
);
const UserIdInput = () => {
const userId = useSelector(({ userId }) => userId)
const dispatch = useDispatch()
const handleChange = (e) => {
dispatch(setUserId(e.target.value))
};
return <UserIdInputComponent value={userId} onChange={handleChange} />;
};
// separated the label from the logic heavy component
export const UserIdInputWithLabel = () => (
<div>
<label>User id: </label>
<UserIdInput />
</div>
)
export default UserIdInputWithLabel
Here is the password input.
// PasswordInput.js
import { useDispatch, useSelector } from "react-redux";
import { setPassword } from "./store";
const PasswordInputComponent = ({ value, onChange }) => (
<input type="password" value={value} onChange={onChange} required />
);
const PasswordInput = () => {
const password = useSelector(({ password }) => password)
const dispatch = useDispatch()
const handleChange = e => {
dispatch(setPassword(e.target.value))
};
return <PasswordInputComponent value={password} onChange={handleChange} />
};
// separated label from logic heavy component
const PasswordInputWithLabel = () => (
<div>
<label>Password: </label>
<PasswordInput />
</div>
)
export default PasswordInputWithLabel
This approach yields the following results:
Fully optimized.
Available here: https://github.com/redpanda-bit/reusable-decoupled-isolated
Conclusion
There you have it, reusable, decoupled, and isolated react components. Very small example but hope that it gives you an idea of how production grade react applications look like. It may be disappointing for some to see all the work that goes into creating a good react component, but I’ll tell ya, once you are faced with a huge form that has complex elements and possibly some animation you will see positive gains on speed. The last thing you want is an input lagging behind in front of a 100 words per minute types.