The file is called Bulletin.tsx and it is four hundred lines long. It knows the URL, the filter, the row, the validation, the error message. The day a mark out of 20 becomes a mark out of 100, you touch everything, and you break the filter.
I don’t split to “make small files”. I split when one block decides two things. Here, three decisions: what to fetch, what to show, what to save.
The block that decides too much
function Bulletin() {
const [notes, setNotes] = useState<Note[]>([]);
const [filter, setFilter] = useState("");
const [value, setValue] = useState("");
useEffect(() => {
fetch("/api/notes/").then((response) => response.json()).then(setNotes);
}, []);
const visible = notes.filter((note) => note.matiere.includes(filter));
async function save() {
const number = Number(value);
if (Number.isNaN(number) || number < 0 || number > 20) return;
await fetch("/api/notes/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ valeur: number }),
});
}
return (
<form onSubmit={(event) => { event.preventDefault(); save(); }}>
<input value={filter} onChange={(event) => setFilter(event.target.value)} />
<ul>
{visible.map((note) => (
<li key={note.id}>{note.matiere} — {note.valeur}/20</li>
))}
</ul>
<input value={value} onChange={(event) => setValue(event.target.value)} />
<button type="submit">Save</button>
</form>
);
}
The filter, the display, and the rule “between 0 and 20” live in the same place. You cannot test the rule without mounting the whole screen.
Three roles
The rule leaves first. It does not know React.
export function isValidMark(value: number, ceiling = 20) {
return Number.isInteger(value) && value >= 0 && value <= ceiling;
}
The row only displays. It does not fetch.
export function MarkRow({ matiere, valeur }: { matiere: string; valeur: number }) {
return (
<li>
{matiere} — {valeur}/20
</li>
);
}
The form receives a decision that was already made, and it says so when the mark is refused. It does not know the URL.
export function MarkForm({ onSave }: { onSave: (value: number) => void }) {
const [raw, setRaw] = useState("");
const [error, setError] = useState("");
const number = Number(raw);
return (
<form
onSubmit={(event) => {
event.preventDefault();
if (!isValidMark(number)) {
setError("A mark sits between 0 and 20.");
return;
}
setError("");
onSave(number);
}}
>
<input value={raw} onChange={(event) => setRaw(event.target.value)} inputMode="numeric" />
{error ? <p>{error}</p> : null}
<button type="submit">Save</button>
</form>
);
}
Bulletin only keeps the list and the filter. It passes onSave to the form. The 20-point rule can be reread in a ten-line function.
Example
Input 21. Before, the silent return: nothing happens, the teacher tries again. After, the sentence “A mark sits between 0 and 20.” stays under the field, and no fetch leaves.
Input 14. isValidMark(14) is true, onSave(14) runs, the list updates. The form does not need to know whether the list came from Django or from a local array.
Run it, to see the rule alone
npx tsx -e "
import { isValidMark } from './src/notes.ts';
console.log(isValidMark(14));
console.log(isValidMark(21));
console.log(isValidMark(14.5));
"
I expect true, false, false. If this script lies, I don’t touch the screen: the rule is not set yet.