Organizing JavaScript code without a framework comes down to a few decisions you make once and then live with: which file holds the rules, which file holds the state, which file is allowed to touch the outside world, and which file wires everything together. Get those four right and a script of any size stays readable without a single dependency.
How do you decide which file a piece of code belongs in?
Ask what the code knows and what it does. A rule like adding a to-do gives it the next id
knows only about to-dos, so it belongs with the other to-do rules. A function that reads a file knows about the file system, so it belongs with the other file-system code. A function that turns a click into a call of the to-do rules knows about the page, so it belongs with the page wiring.
That single question — what does this code know about? — settles most placement arguments. When two files both want to own a piece of logic, the one that already knows the surrounding data usually wins.
What does a workable folder layout look like?
Four files in one folder is enough to start, and the names describe roles rather than features. The suffixes here are the ones Node expects for ES modules without any extra configuration; if you prefer plain .js names, add a package.json with the type set to module, and browsers load the same files as modules either way.
src/
todos.mjs the rules about to-dos, no imports at all
store.mjs holds the current state, no imports at all
storage.mjs reads and writes saved data, no imports at all
main.mjs imports the other three and wires them together
There is no utils file and no helpers file, and that is deliberate. Those names collect code nobody can place, and a file with five unrelated functions has no owner. If two features need the same helper, that is a sign the helper wants a name of its own.
Why should the core files have no imports at all?
Because the moment a rule file imports a browser API or a file system call, that rule can only be tested in that environment. A rule that takes a state and returns a new state can be tested anywhere, in a fraction of a millisecond, with no setup and no cleanup.
export function addTodo(state, title) {
const clean = String(title).trim();
if (!clean) throw new Error('a to-do needs a title');
const id = state.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
return { ...state, todos: [...state.todos, { id, title: clean, done: false }] };
}
export function toggleTodo(state, id) {
if (!state.todos.some((todo) => todo.id === id)) {
throw new Error('no to-do with id ' + id);
}
return {
...state,
todos: state.todos.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo)),
};
}
export function visibleTodos(state, filter = 'all') {
if (filter === 'done') return state.todos.filter((todo) => todo.done);
if (filter === 'open') return state.todos.filter((todo) => !todo.done);
return state.todos;
}
Every function here takes a state and returns a state. None of them change the state they were given, so you can call them in any order, print the result of an old call, and be certain nothing was quietly modified underneath you.
Keeping the state container equally free of imports makes it reusable for a different kind of application later.
export function createStore(initialState = { todos: [] }) {
let state = initialState;
const listeners = new Set();
return {
getState() {
return state;
},
update(recipe) {
state = recipe(state);
listeners.forEach((listener) => listener(state));
return state;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
The store holds one value, hands it out on request, and tells subscribers when it changes. It has no opinion about what that value means, which is why the to-do rules and the store never need to know about each other.
How do you stop two modules from importing each other?
Pick a direction and keep every arrow pointing the same way. The rule files, the store and the storage adapter never import each other; only the entry file imports them. Imports then form a picture instead of a web.
main.mjs ---> todos.mjs
---> store.mjs
---> storage.mjs
todos.mjs, store.mjs, storage.mjs ---> (nothing)
When two modules need each other, the answer is almost never to let them import each other. Pull the shared piece up into a third module, or push the decision down into the entry file, which is the one place allowed to know about everything.
What about the file that does have to touch the outside?
One file is allowed to reach out, and it does so through a small adapter with two functions handed to it. The adapter is where the file system, the network or the browser storage lives; the rest of the program only knows the two operations.
export function createStorage({ read, write }) {
return {
async load() {
const raw = await read();
return raw ? JSON.parse(raw) : { todos: [] };
},
async save(state) {
await write(JSON.stringify(state));
},
};
}
Nothing in that file knows about files. The entry file decides what reading and writing actually mean, which is why the same rules, the same store and the same wiring work in a browser with a local-storage adapter, in a server with a database adapter, or in a test with an adapter that keeps everything in memory.
Two habits make this layout hold up over time. Name files after the role they play rather than the feature they serve, and keep the entry file short enough to read in one screen. If the wiring grows past that, the pieces it wires have stopped being simple enough, and that is the signal to split them further.
Where does the wiring belong?
In one file, and that file is the only one that imports anything. It builds the pieces, connects them, and starts the work. If the entry file is doing real logic rather than connecting things, that logic belongs in a rule file.
import { createStore } from './store.mjs';
import { createStorage } from './storage.mjs';
import { addTodo, toggleTodo, visibleTodos } from './todos.mjs';
const storage = createStorage({
read: async () => JSON.stringify({ todos: [] }),
write: async (text) => console.log('saved:', text),
});
const store = createStore({ todos: [] });
store.subscribe((state) => {
console.log('state changed, to-do count:', state.todos.length);
});
async function main() {
const saved = await storage.load();
store.update(() => saved);
store.update((state) => addTodo(state, 'write the module docs'));
store.update((state) => toggleTodo(state, 1));
const state = store.getState();
console.log('open to-dos:', visibleTodos(state, 'open').length);
console.log('total to-dos:', visibleTodos(state, 'all').length);
await storage.save(state);
}
main();
Running the file prints:
state changed, to-do count: 0
state changed, to-do count: 1
state changed, to-do count: 1
open to-dos: 0
total to-dos: 1
saved: {"todos":[{"id":1,"title":"write the module docs","done":true}]}
Read it as a story: the store starts empty, a to-do is added, that to-do is marked as finished, and the numbers are printed. Each line of that story is one call into a rule file, and no rule file knew that a store existed.
Rules for a framework-free layout
- Give each file one job, and name it after that job instead of after a feature or a catch-all word like helpers.
- Keep the rule files free of imports so they can be tested anywhere without setup.
- Let exactly one file import the others, and keep that file to wiring rather than logic.
- Point every import in the same direction so the dependency picture stays readable.
- Push side effects into one adapter that receives the real operations from the entry file.
- Return new state instead of changing it, so the rules can be tested in any order.
Try it yourself: Build the four-file layout above on your machine, then add a fifth file that filters the list by a search word, written as a rule that takes a state and returns a list. Wire it into the entry file and confirm it prints only the matching to-dos. Next, replace the in-memory storage adapter with one that reads and writes a real file, and check that a to-do added in one run is still there in the next. Finally, make the rule file import something it does not need, and see how quickly the import graph becomes harder to follow than the code itself.