Introduction
Writing code that's both easy to test and easy to read can be a challenge, especially with Vue components. In this blog post, I'm going to share a design idea that will make your Vue components better.
This method won't speed up your code, but it will make it simpler to test and understand. Think of it as a big-picture way to improve your Vue coding style. It's going to make your life easier when you need to fix or update your components.
Whether you're new to Vue or have been using it for some time, this tip will help you make your Vue components cleaner and more straightforward.
Understanding Vue Components
A Vue component is like a reusable puzzle piece in your app. Usually, it has three main parts:
- View: This is the template section where you design the user interface.
-
Reactivity: Here, Vue's features like
ref
make the interface interactive. - Business Logic: This is where you process data or manage user actions.
Case Study: snakeGame.vue
Let's look at a common Vue component, snakeGame.vue
. It mixes the view, reactivity, and business logic, which can make it complex and hard to work with.
Code Sample: Traditional Approach
<template>
<div class="game-container">
<canvas ref="canvas" width="400" height="400"></canvas>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
let snake = [{ x: 200, y: 200 }];
let direction = { x: 0, y: 0 };
let lastDirection = { x: 0, y: 0 };
let food = { x: 0, y: 0 };
const gridSize = 20;
let gameInterval: number | null = null;
onMounted(() => {
if (canvas.value) {
ctx.value = canvas.value.getContext('2d');
resetFoodPosition();
gameInterval = setInterval(gameLoop, 100);
}
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
function handleKeydown(e: KeyboardEvent) {
e.preventDefault();
switch (e.key) {
case 'ArrowUp': if (lastDirection.y !== 0) break; direction = { x: 0, y: -gridSize }; break;
case 'ArrowDown': if (lastDirection.y !== 0) break; direction = { x: 0, y: gridSize }; break;
case 'ArrowLeft': if (lastDirection.x !== 0) break; direction = { x: -gridSize, y: 0 }; break;
case 'ArrowRight': if (lastDirection.x !== 0) break; direction = { x: gridSize, y: 0 }; break;
}
}
function gameLoop() {
updateSnakePosition();
if (checkCollision()) {
endGame();
return;
}
checkFoodCollision();
draw();
lastDirection = { ...direction };
}
function updateSnakePosition() {
for (let i = snake.length - 2; i >= 0; i--) {
snake[i + 1] = { ...snake[i] };
}
snake[0].x += direction.x;
snake[0].y += direction.y;
}
function checkCollision() {
return snake[0].x < 0 || snake[0].x >= 400 || snake[0].y < 0 || snake[0].y >= 400 ||
snake.slice(1).some(segment => segment.x === snake[0].x && segment.y === snake[0].y);
}
function checkFoodCollision() {
if (snake[0].x === food.x && snake[0].y === food.y) {
snake.push({ ...snake[snake.length - 1] });
resetFoodPosition();
}
}
function resetFoodPosition() {
food = {
x: Math.floor(Math.random() * 20) * gridSize,
y: Math.floor(Math.random() * 20) * gridSize,
};
}
function draw() {
if (!ctx.value) return;
ctx.value.clearRect(0, 0, 400, 400);
drawGrid();
drawSnake();
drawFood();
}
function drawGrid() {
if (!ctx.value) return;
ctx.value.strokeStyle = '#ddd';
for (let i = 0; i <= 400; i += gridSize) {
ctx.value.beginPath();
ctx.value.moveTo(i, 0);
ctx.value.lineTo(i, 400);
ctx.value.stroke();
ctx.value.moveTo(0, i);
ctx.value.lineTo(400, i);
ctx.value.stroke();
}
}
function drawSnake() {
ctx.value.fillStyle = 'green';
snake.forEach(segment => {
ctx.value.fillRect(segment.x, segment.y, gridSize, gridSize);
});
}
function drawFood() {
ctx.value.fillStyle = 'red';
ctx.value.fillRect(food.x, food.y, gridSize, gridSize);
}
function endGame() {
clearInterval(gameInterval as number);
alert('Game Over');
}
</script>
<style>
.game-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
</style>
Screenshot from the game
Challenges with the Traditional Approach
When you mix the view, reactivity, and business logic all in one file, the component often becomes bulky and hard to maintain. It also becomes difficult to test with unit tests, as you mostly end up doing more complex integration tests.
Introducing the Functional Core, Imperative Shell Pattern
To solve these problems in Vue, we use the "Functional Core, Imperative Shell" pattern. This pattern is key in software architecture and helps you structure your code better:
Functional Core, Imperative Shell Pattern: In this design, the main logic of your app (the 'Functional Core') stays pure and without side-effects, making it easy to test. The 'Imperative Shell' then deals with the outside world, like the UI or databases, and talks to the pure core.
What Are Pure Functions?
In this pattern, pure functions are at the heart of the 'Functional Core'. A pure function is a concept from functional programming and it's special for two reasons:
- Predictability: If you give a pure function the same inputs, it always gives back the same output.
- No Side Effects: Pure functions don't change anything outside of them. They don't alter external variables, call APIs, or do any input/output.
Pure functions are simpler to test, debug, and understand. They are the foundation of the Functional Core, keeping your app's business logic clean and manageable.
Applying the Pattern in Vue
In Vue, this pattern has two parts:
-
Imperative Shell (
useGameSnake.ts
): This part handles the Vue-specific reactive bits. It's where your components interact with Vue, managing things like state changes and events. -
Functional Core (
pureGameSnake.ts
): This is where your pure business logic lives. It's separate from Vue, which makes it easier to test and think about your app's main functions, independent of the UI.
Implementing pureGameSnake.ts
The pureGameSnake.ts
file encapsulates the game's business logic without any Vue-specific reactivity. This separation means easier testing and clearer logic.
export const gridSize = 20;
export function initializeSnake() {
return [{ x: 200, y: 200 }];
}
export function moveSnake(snake, direction) {
let newSnake = snake.map((segment, index) => {
if (index === 0) {
return { x: segment.x + direction.x, y: segment.y + direction.y };
}
return { ...snake[index - 1] };
});
return newSnake;
}
export function isCollision(snake) {
let head = snake[0];
let hasCollided = head.x < 0 || head.x >= 400 || head.y < 0 || head.y >= 400 ||
snake.slice(1).some(segment => segment.x === head.x && segment.y === head.y);
return hasCollided;
}
export function randomFoodPosition() {
return {
x: Math.floor(Math.random() * 20) * gridSize,
y: Math.floor(Math.random() * 20) * gridSize,
};
}
export function isFoodEaten(snake, food) {
let head = snake[0];
return head.x === food.x && head.y === food.y;
}
Implementing useGameSnake.ts
In useGameSnake.ts
, we manage the Vue-specific state and reactivity, leveraging the pure functions from pureGameSnake.ts
.
import { onMounted, onUnmounted, ref } from 'vue';
import * as GameLogic from './pureGameSnake.ts';
export function useGameSnake() {
const snake = ref(GameLogic.initializeSnake());
const direction = ref({ x: 0, y: 0 });
const food = ref(GameLogic.randomFoodPosition());
const gameState = ref<'over' | 'playing'>('playing');
let gameInterval = null;
const startGame = () => {
gameInterval = setInterval(() => {
snake.value = GameLogic.moveSnake(snake.value, direction.value);
if (GameLogic.isCollision(snake.value)) {
gameState.value = 'over';
clearInterval(gameInterval);
} else if (GameLogic.isFoodEaten(snake.value, food.value)) {
snake.value.push({ ...snake.value[snake.value.length - 1] });
food.value = GameLogic.randomFoodPosition();
}
}, 100);
};
onMounted(startGame);
onUnmounted(() => {
clearInterval(gameInterval);
});
return { snake, direction, food, gameState };
}
Refactoring gameSnake.vue
Now, our gameSnake.vue
is more focused, using useGameSnake.ts
for managing state and reactivity, while the view remains within the template.
<template>
<div class="game-container">
<canvas ref="canvas" width="400" height="400"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted } from 'vue';
import { useGameSnake } from './useGameSnake.ts';
import { gridSize } from './pureGameSnake';
const { snake, direction, food, gameState } = useGameSnake();
const canvas = ref<HTMLCanvasElement | null>(null);
const ctx = ref<CanvasRenderingContext2D | null>(null);
let lastDirection = { x: 0, y: 0 };
onMounted(() => {
if (canvas.value) {
ctx.value = canvas.value.getContext('2d');
draw();
}
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
watch(gameState, (state) => {
if (state === 'over') {
alert('Game Over');
}
});
function handleKeydown(e: KeyboardEvent) {
e.preventDefault();
switch (e.key) {
case 'ArrowUp': if (lastDirection.y !== 0) break; direction.value = { x: 0, y: -gridSize }; break;
case 'ArrowDown': if (lastDirection.y !== 0) break; direction.value = { x: 0, y: gridSize }; break;
case 'ArrowLeft': if (lastDirection.x !== 0) break; direction.value = { x: -gridSize, y: 0 }; break;
case 'ArrowRight': if (lastDirection.x !== 0) break; direction.value = { x: gridSize, y: 0 }; break;
}
lastDirection = { ...direction.value };
}
watch([snake, food], () => {
draw();
}, { deep: true });
function draw() {
if (!ctx.value) return;
ctx.value.clearRect(0, 0, 400, 400);
drawGrid();
drawSnake();
drawFood();
}
function drawGrid() {
if (!ctx.value) return;
ctx.value.strokeStyle = '#ddd';
for (let i = 0; i <= 400; i += gridSize) {
ctx.value.beginPath();
ctx.value.moveTo(i, 0);
ctx.value.lineTo(i, 400);
ctx.value.stroke();
ctx.value.moveTo(0, i);
ctx.value.lineTo(400, i);
ctx.value.stroke();
}
}
function drawSnake() {
ctx.value.fillStyle = 'green';
snake.value.forEach(segment => {
ctx.value.fillRect(segment.x, segment.y, gridSize, gridSize);
});
}
function drawFood() {
ctx.value.fillStyle = 'red';
ctx.value.fillRect(food.value.x, food.value.y, gridSize, gridSize);
}
</script>
<style>
.game-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
</style>
Advantages of the Functional Core, Imperative Shell Pattern
The Functional Core, Imperative Shell pattern greatly enhances the testability and maintainability of Vue components. By decoupling the business logic from the framework-specific code, this pattern offers several key advantages:
Simplified Testing
When business logic is intertwined with Vue's reactivity and component structure, testing can be cumbersome. Traditional unit testing becomes challenging, often leading to reliance on integration tests which are less granular and more complex. By extracting the core logic into pure functions (as in pureGameSnake.ts
), we can easily write unit tests for each function. This isolation simplifies testing dramatically, as each piece of logic can be tested independently of Vue's reactivity system.
Enhanced Maintainability
The Functional Core, Imperative Shell pattern results in a clearer separation of concerns. Vue components become leaner, focusing mainly on the user interface and reactivity, while the pure business logic resides in separate, framework-agnostic files. This separation makes the code easier to read, understand, and modify. Maintenance becomes more manageable, especially as the application scales.
Framework Agnosticism
A significant advantage of this pattern is the portability of your business logic. The pure functions in the Functional Core are not tied to any specific UI framework. Should you ever need to switch from Vue to another framework, or if Vue undergoes major changes, your core logic remains intact and reusable. This flexibility safeguards your code against technological shifts and changes in project requirements.
Testing Complexities in Traditional Vue Components vs. Functional Core, Imperative Shell Pattern
Challenges in Testing Traditional Components
Testing traditional Vue components, where view, reactivity, and business logic are all intertwined, can be quite challenging. In such components, unit tests become difficult to implement effectively because:
- The tests often end up being more like integration tests, which are broader and less precise.
- Mocking dependencies and Vue's reactivity system can be complicated and time-consuming.
- Ensuring that tests cover all aspects of the component’s functionality, including its reactive behavior and side effects, adds complexity.
This complexity in testing can lead to less confidence in the tests and, by extension, the stability of the component itself.
Simplified Testing with Functional Core, Imperative Shell Pattern
By refactoring components to use the Functional Core, Imperative Shell pattern, testing becomes much more straightforward:
- Isolated Business Logic: With pure functions in the Functional Core, you can write simple unit tests for your business logic without worrying about Vue's reactivity or component states.
- Predictable Outcomes: Pure functions produce predictable outputs for given inputs, making them easy to test.
- Reduced Complexity: Since the reactive and side-effect-laden parts of your code are isolated in the Imperative Shell, you can focus on testing the interaction with Vue’s reactivity separately. This separation simplifies the testing of each part.
The end result is a more modular, testable, and maintainable codebase, where each piece can be tested in isolation, leading to higher quality and more reliable Vue components.
Conclusion
Implementing the Functional Core, Imperative Shell pattern in Vue applications leads to a more robust, testable, and maintainable codebase. It not only aids in the current development process but also prepares your code for future changes and scalability. This approach, while requiring an upfront effort in restructuring, pays off significantly in the long run, making it a wise choice for any Vue developer looking to improve their application's architecture and quality.
Enjoyed this post? Follow me on X for more Vue and TypeScript content:
Top comments (13)
I like this article very much. The seperation of the business logic really suits me well. It leans in a DDD direction of usecases I think.
github.com/attikos/ddd-vue-match-game this is taking it even further.
Thank you for your comment. The Imperative Shell/Functional Core pattern aims to leverage the best aspects of functional programming and pure functions without the complexity of other concepts, such as how functional programming deals with side effects using monads. You can argue that all these architectural designs always have the main topic of separating core business logic and creating layers that aid in maintainability. I also find Domain-Driven Design (DDD) interesting and need to learn more about that.
Very interesting approach! I think I already use this way of thinking on the React ⚛️ side (without putting a name to it...) I will extend this approach to other reactive frameworks ;) Thanks for sharing 🙏
I loved the article! Recently, I discovered Hexagonal Architecture as a way to separate business logic from the UI. I wanted to tidy up a feature in my Vue app to make it easier to test automatically. I managed to do that, but I think the team found it a bit overwhelming, especially the object-oriented structure I introduced. This approach seems simpler yet achieves the same results.
To be honest, I still like the OOP approach, but I want to keep things as simple as possible.
Yes, thank you for the comment. There are many ways that can help us in the frontend world to write better, maintainable code. I have the feeling that the frontend has become more complicated over the years, but we don't use useful tools to structure our applications like they do in the backend world with clean architecture or hexagonal architecture. The simplest thing is to have a consistent folder structure and extract business logic into .ts files that don't have any dependencies and would work in any framework.
Would it make sense to also externalize the canvas logic? Since that is also framework agnostic.
Hmm, I'm not sure about that. In the end, you can extract and put in new layers; only time will then tell if it had any benefit.
It is very good, however the change in the code of the main component was not much. It is still a bit long and dirty. The advantages you mention are 3. I for example do not use tests in the frontend, and it is unlikely that someone in your project changes Frameworks often, the only strong point I see is that it improves maintainability, and the latter is strong enough to use this approach. Where can I learn more about this topic? Any courses? A book? Or more articles? Thanks
Thank you for your comment. In a blog post, it's hard to find a good example to explain the idea. But in my experience, this pattern can really help with readability. You can Google the "Functional Core, Imperative Shell'" pattern; there are more articles out there for different languages. Also, what helps is to learn a functional programming language like Haskell, then you see the beauty of pure functions more. Unfortunately, for the frontend world, there are not many good books or courses about architecture design ideas out there. For backend, there are many, like Clean Architecture, Hexagonal Architecture, etc.
You can also try fsd(feature sliced design). I think this is really good for large projects and clean code.
Any good resoruce to learn FSD. Please share if you know.
found this maybe it will help you
blog.stackademic.com/frontend-mast...
Thanks. Nice article but still practice is important.