Sinatra Tutorial: Building a To-Do Application

Sinatra is a Ruby microframework that provides a lightweight and flexible way to build web applications, giving developers a high level of control over every aspect of the application’s flow. Unlike more robust frameworks like Ruby on Rails, Sinatra doesn’t impose a strict structure, making it an excellent tool for deeply understanding how web applications work without the added complexity of automatic configurations or rigid conventions.
In our case, we’ll use Sinatra as a hands-on lab to learn the fundamentals of the interaction between web applications and the HTTP protocol. This includes how requests are processed, how responses are constructed, and how HTTP methods are handled.
Throughout the development, we will explore the essential components of Sinatra and basic web architecture, including:
- Routes: How to define endpoints that respond to different URLs and HTTP methods.
- Parameters: Handling data sent through the URL or forms.
- ERB Templates: Using templates to generate dynamic HTML content.
- Layouts: Creating reusable common structures for views.
- Forms and PRG (Post/Redirect/Get): Implementing secure and organized form workflows.
- Sessions: Persisting information across different requests from the same user.
As a practical project, we will develop an application to manage a to-do list stored in a local file. You will learn to:
- Display and add new tasks using forms.
- Validate user input on the server.
- Manage state between requests using sessions.
- Structure and complete a full CRUD application in the final exercise.
This step-by-step approach will allow us to understand both the internal logic of a web application and the best practices for handling data, views, and routes using Sinatra.
What is Sinatra?
Sinatra calls itself a “Domain Specific Language (DSL)” rather than a traditional framework. This distinction is fundamental to understanding its philosophy. If we compare Sinatra to Rails, we could think of a bicycle versus a car. Rails (the car) comes packed with features, strict conventions, and everything needed for long and complex journeys. Sinatra (the bicycle) is lightweight, fast, requires fewer resources, and gives you complete freedom over your application’s structure. It doesn’t impose conventions; instead, it provides an expressive set of tools for handling web requests. Despite their differences, it’s important to note that both are built on the same foundation: Rack, the standard interface for Ruby web applications.
The Concept of a DSL (Domain Specific Language)
A “Domain Specific Language” or DSL is, in essence, a specialized vocabulary designed to solve problems within a specific domain. In the context of Ruby, a DSL leverages the language’s flexibility to create a syntax that reads almost like a natural language oriented to the problem. Sinatra’s domain is building web applications that “speak” HTTP.
To appreciate its value, consider the verbose code that would be needed to handle a request without a DSL:
def handle_request(method, path) if method == "GET" [200, { "Content-Type" => "text/html" }, ["You requested the path #{path} using GET"]] else [405, {}, ["Method not allowed: #{method}"]] endend
Sinatra transforms this logic into a much clearer and more descriptive expression, elegantly covering multiple HTTP verbs:
get "/path" do "You requested the path /path"end
post "*" do # Returns an error for any other POST route status 405end
This code uses Sinatra’s “language” (get
, post
, status
, etc.) to narratively describe how the application should respond to HTTP requests. This declarative approach is at the heart of Sinatra’s simplicity and power.
Now that we have laid the theoretical groundwork, it’s time to put these concepts into practice and build our first application.
Fundamentals: Your First Application and Route Handling
To understand Sinatra, it’s crucial to start with its most basic structure in a functional application. From there, we will explore Sinatra’s central mechanism for managing incoming requests: routes. We’ll go from running a simple “Hello World” to handling dynamic URLs.
Creating the Application
Let’s start with the simplest possible application, tailored to our to-do list theme.
Create a directory for the project and initialize it with Bundler. Then, install the Sinatra, Puma (the web server we will use), and Rack gems (because Sinatra uses it as its engine):
mkdir todo-listcd todo-listbundle initbundle add sinatra rackup puma
From here, I recommend opening the directory with your favorite code editor or IDE.
Next, create a file named app.rb
with the following code:
require 'sinatra'
get '/' do 'My To-Do List'end
Run the file from your terminal:
bundle exec ruby app.rb
You will see output similar to this, indicating that the server is running:
== Sinatra (v4.1.1) has taken the stage on 4567 for development with backup from PumaPuma starting in single mode...
The application is now listening on the default port 4567
. If you open your browser and visit http://localhost:4567
, you will see the text “My To-Do List”.
Congratulations! You have created your first Sinatra application.
Defining Routes
In Sinatra, a route is the association between an HTTP verb (like GET
) and a URL pattern. When an incoming request matches both, the Ruby code block associated with that route is executed.
We can define multiple routes to handle different parts of our application:
# Displays the main list of tasksget '/' do 'All tasks will be displayed here.'end
# Displays the form to create a new taskget '/tasks/new' do 'The form to add a new task will be here.'end
Sinatra will search for a match from top to bottom in the file. The first route that matches the verb and the URL pattern will handle the request.
Dynamic Parameters in Routes
Real applications need to handle dynamic URLs, such as displaying a specific task by its ID. Sinatra achieves this with route patterns. A URL segment preceded by a colon (:
) acts as a wildcard that captures any value in that position.
get '/tasks/:id' do "Showing task with ID: #{params['id']}"end
If you visit http://localhost:4567/tasks/123
, Sinatra will capture "123"
and make it accessible through the params
hash. This object, called params
, is where Sinatra stores all relevant data from the request, including route parameters, query string parameters, and form data.
It’s important to note that params
is a Sinatra::IndifferentHash
, which means you can access its values using both strings (params['id']
) and symbols (params[:id]
).
Returning HTML as a String
So far, our routes return plain text. However, the browser can interpret HTML if we send it directly as a string. For simple responses, this can be a quick and effective solution.
Let’s modify the previous route to return an H1
heading:
get '/tasks/:id' do "<h1>Showing task with ID: #{params['id']}</h1>"end
If you now visit http://localhost:4567/tasks/123
, you will see the text formatted as a main heading in your browser. This demonstrates that Sinatra sets the Content-Type
of the response to text/html
by default, allowing the browser to render the tags.
Although returning HTML as a string is useful for quick prototypes or very simple responses, it becomes difficult to maintain as the HTML structure grows. To separate logic from presentation cleanly, the next step is to learn how to use templates.
Presentation: Rendering Views with ERB
Separating application logic from the presentation layer is a fundamental practice in web development. Sinatra natively integrates ERB (Embedded Ruby), Ruby’s standard solution for embedding dynamic code within HTML templates. This allows us to build complex user interfaces and keep our route code clean and focused on its main task: handling requests.
Dynamic Rendering with ERB
Before using templates in separate files, it’s helpful to understand how Sinatra simplifies rendering ERB directly in the route.
Native ERB in the Route
ERB (Embedded Ruby) is a standard Ruby library. We can use it directly to process a string with embedded Ruby code. To do this, we need to require 'erb'
and use ERB.new(template).result(binding)
so that the route’s local variables are available.
require 'sinatra'require 'erb'
get '/tasks/:id' do template = "<h1>Task Details: <%= params[:id] %></h1>"
ERB.new(template).result(binding)end
ERB.new
creates a template object, and result(binding)
processes it in the current context.
The erb
Helper with Inline Templates
The above code works, but it’s verbose. Sinatra offers the erb
helper, which does the same thing in a more readable way. The erb
helper can receive a string as a template, simplifying the code:
require 'sinatra'
get '/tasks/:id' do erb "<h1>Task Details: <%= id %></h1>", locals: { id: params[:id] }end
As you can see, the erb
helper handles the entire process for us and allows us to explicitly pass data through locals
. Although this approach is cleaner, it still mixes presentation (HTML) with logic. The true power of the erb
helper is revealed when we use it to render templates from external files, which is the recommended practice.
Passing Data to Views: locals
vs. Instance Variables
There are two main ways to pass data from a route to an ERB template, each with its own advantages.
Using the locals
hash
This method is explicit and encapsulated. A hash is passed to the :locals
option of the erb
helper, and each key in that hash becomes a local variable within the template.
get '/tasks/:id' do erb :show, locals: paramsend
By convention, Sinatra looks for template files in a directory named views
at the root of your project. Let’s create this structure:
- Create a directory named
views
. - Inside
views
, create a file namedshow.erb
. - Add the following content to access
id
as if it were a local variable:
<h2>Task Details: <%= id %></h2>
Now, visiting http://localhost:4567/tasks/123
will cause Sinatra to render this HTML file, replacing <%= id %>
with the value 123
.
The advantage of locals
is that it creates a clear “contract”: the view can only access the data that has been explicitly passed to it, making the code more predictable.
Using Instance Variables (the “Rails Way”)
This is the most common way in the Ruby ecosystem, popularized by Rails. Any instance variable (starting with @
) defined in the route block is automatically available in the rendered template.
get '/tasks/:id' do @id = params[:id]
erb :showend
In the template, it is accessed with the same syntax:
<h2>Task Details: <%= @id %></h2>
This method is more concise, though less explicit, as there is no formal declaration of the data the view expects to receive. Throughout this tutorial, we will primarily use instance variables as it is a very widespread convention.
Implementing a Global Layout
Most pages on a website share a common structure (header, footer, stylesheets, etc.). Repeating this code in every template is inefficient. Sinatra solves this with layouts, which act as “wrapper” templates.
Sinatra automatically looks for a file named layout.erb
in the views
directory and applies it to all views rendered with erb
. The special tag <%= yield %>
within the layout indicates where the content of the specific view should be inserted.
Create the file views/layout.erb
and add the following content:
<!DOCTYPE html><html><head> <title>My Task Application</title></head><body> <header> <h1>Task Manager</h1> </header> <main> <%= yield %> </main></body></html>
Without needing to change your route code, Sinatra will now wrap the output of show.erb
(and any other view) within this layout.
Now that we can display dynamic data with a consistent structure, the next step is to allow users to input their own data through forms.
Interactivity with Forms
User interactivity is at the core of dynamic web applications. In this section, we will cover the full cycle of user interaction: presenting a form to capture data, processing that data on the server via a POST
request, and managing the application flow in a predictable and secure manner.
Creating an HTML Form
The first step is to provide the user with an interface to enter data. We will create a simple HTML form to add a new task.
Create a new view in views/new.erb
:
<h2>Create New Task</h2>
<form action="/tasks" method="post"> <label for="description">New Task:</label> <input type="text" name="description" id="description"> <input type="submit" value="Create Task"></form>
Key form attributes:
action="/tasks"
: Specifies the URL to which the form data will be sent.method="post"
: Indicates that the HTTPPOST
verb should be used, which is appropriate for creating new resources.
Add a GET
route in app.rb
to display this form:
get '/tasks/new' do erb :newend
Now, by visiting http://localhost:4567/tasks/new
, the user will see the form to create a task.
Processing Data with POST
When the user submits the form, the browser makes a POST
request to /tasks
. The form data (in this case, the description
field) will be available in the params
hash.
Let’s implement the POST
route to receive and store this data. For simplicity, we will save the tasks in a text file named tasks.txt
.
post '/tasks' do task_description = params['description']
File.open('tasks.txt', 'a+') do |file| file.puts(task_description) endend
In this block, File.open('tasks.txt', 'a+')
opens the file tasks.txt
. The 'a+'
mode is important: it means append
and will also create the file if it does not exist. This ensures that each new task is added without deleting the previous ones.
Our route already saves the task, but now we face an important question: what response do we send back to the browser?
The Post/Redirect/Get (PRG) Pattern
Once we process a POST
request, we face an important question: what response do we send back to the browser? A bad practice is to render a view template directly from a POST
route. If the user reloads the page, the browser will resubmit the POST
request, creating the same task twice.
The standard solution is the Post/Redirect/Get (PRG) pattern:
- POST: The client sends data to create or update a resource.
- Redirect: The server, after processing the request, responds with a redirection (status code
303 See Other
) to a new URL. - GET: The browser follows the redirection and makes a
GET
request to the new URL, which displays the updated state of the application.
Sinatra facilitates this with the redirect
helper. Let’s modify our POST
route to implement this pattern:
post '/tasks' do task_description = params['description']
File.open('tasks.txt', 'a+') do |file| file.puts(task_description) end
redirect '/tasks'end
Now, after creating a task, the user is safely redirected to the home page, avoiding the problem of form resubmission. This flow is more robust and predictable.
Displaying the Task List
Now that we save tasks and redirect to the main page, it’s time to make that page (get '/'
) display the list. Let’s update it to read the tasks.txt
file and render a new view.
Update the get '/'
route in app.rb
to read all tasks from the file (if it exists). We’ll use File.readlines
to return an array with each line of the file.
get '/' do @tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt') : []
erb :indexend
Create the views/index.erb
view. This view will be responsible for iterating over the @tasks
variable and displaying each one in a list.
<h2>My Tasks</h2>
<% if @tasks.empty? %> <p>No pending tasks. <a href="/tasks/new">Add one!</a></p><% else %> <ul> <% @tasks.each do |task| %> <li><%= task %></li> <% end %> </ul><% end %>
<p> <a href="/tasks/new">Add a new task</a></p>
With these changes, our application is now functional: we can add tasks and see them reflected on the main page.
To improve the user experience, the next step is to display a confirmation message like “Task created successfully.” To achieve this, we need a way to maintain information between the POST
request and the subsequent GET
request, which introduces us to the concept of sessions.
State Management: Sessions and Flash Messages
The HTTP protocol is inherently “stateless”, which means each request is an independent transaction with no memory of previous ones.
To create coherent user experiences, such as displaying confirmation messages or keeping a user authenticated, we need a mechanism to preserve information between requests. Sessions are the standard solution to this problem, allowing an application to remember a user across multiple interactions.
Enabling and Using Sessions
In Sinatra, sessions are not enabled by default. We must explicitly activate them in our application. Once enabled, Sinatra provides us with a session
object, which works like a hash where we can store data.
Add enable :sessions
at the top of your app.rb
file:
require 'sinatra'
enable :sessions
# ... rest of the routes ...
Sinatra manages sessions using cookies. When we store something in the session
hash, Sinatra sends a cookie to the user’s browser. In subsequent requests, the browser returns that cookie, allowing Sinatra to reconstruct the session state for that specific user.
Implementing Flash Messages
A “flash message” is a type of transient state: information that should only be displayed once and then disappear. A perfect example is the “Task created successfully” message after using a form.
We will implement this pattern by following three steps:
Save the message in the session: In our POST
route, after saving the task, we store a message in the session
hash.
post '/tasks' do task_description = params['description']
File.open('tasks.txt', 'a+') do |file| file.puts(task_description) end
session[:message] = "Task '#{task_description}' created successfully."
redirect '/'end
Read and delete the message: In the GET
route to which we redirect (in this case, /
), we read the message from the session. It is crucial to delete it immediately after reading it so that it does not show up again on the next visit. The delete
method of a hash is perfect for this, as it returns the value and removes it in a single operation.
get '/' do @message = session.delete(:message)
@tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt') : []
erb :indexend
Display the message in the view: In the corresponding view (layout.erb
is a good place for global messages), we add a conditional block to display the message only if it exists.
<!DOCTYPE html><html><head> <title>My Task Application</title></head><body> <header> <h1>Task Manager</h1> </header> <main> <% if @message %> <p><%= @message %></p> <% end %>
<%= yield %> </main></body></html>
With this pattern, we can now provide clear feedback to the user after each action. In addition to success messages, a crucial use of state between requests is to manage validation errors, which is our next topic.
Hardening the Application: Validations
Validating user data is one of the most critical responsibilities of a web application. You should never trust data that comes from the client. Server-side validation is an indispensable layer of security and consistency that prevents corrupt data, application errors, and security vulnerabilities.
The Validation Cycle
When a user submits a form, we must follow a clear and consistent validation cycle. This pattern ensures that only valid data is processed and that the user receives useful feedback in case of an error.
- The user submits data to a
POST
route. - The route validates the received data.
- If the data is valid:
- The data is processed (e.g., saved to the file or database).
- A success message is set in the session (
session[:message]
). - The user is redirected to another page (following the PRG pattern).
- If the data is invalid:
- DO NOT redirect. Redirecting would erase the error context.
- The original form template is re-rendered.
- An error message and the original data are passed to the template so the user can correct their input without having to re-type everything.
Implementation
Let’s modify our post '/tasks'
route to include a simple validation: the task description cannot be empty.
Update the post '/tasks'
route:
post '/tasks' do @task_description = params['description'].strip
# Validation if @task_description.empty? # Failure case: invalid data @error = "The task description cannot be empty."
erb :new # Re-render the form else # Success case: valid data File.open('tasks.txt', 'a+') do |file| file.puts(@task_description) end session[:message] = "Task '#{@task_description}' created successfully."
redirect '/' endend
Update the form view (views/new.erb
):
Now we need the form view to be able to display the error message and pre-fill the text field with the value the user had already entered.
<h2>Create New Task</h2>
<% if @error %> <p><%= @error %></p><% end %>
<form action="/tasks" method="post"> <label for="description">New Task:</label> <input type="text" name="description" id="description" value="<%= @task_description %>"> <input type="submit" value="Create Task"></form>
With this implementation, if the user tries to submit an empty form, they will see an error message and the form again, ready to be corrected. If the data is correct, the PRG flow executes as before.
Refactoring: Extracting Logic to a Validator Class
As an application grows, validation logic can become more complex. We might want to add rules like a minimum length, check for duplicate tasks, or validate specific formats. Placing all this logic within the POST
route can make it difficult to read and maintain.
A recommended practice is to extract this logic into its own class, following the Single Responsibility Principle. We will create a TaskValidator
class whose sole purpose is to validate task data.
-
Create the
TaskValidator
class: You can add it at the end of yourapp.rb
or, ideally, in its own file (e.g.,task_validator.rb
) and require it.task_validator.rb class TaskValidatorattr_reader :messagedef initialize(description)@description = description.to_s.strip@message = nilenddef valid?validate@message.nil?endprivatedef validateif @description.empty?@message = "The task description cannot be empty."elsif @description.length < 3@message = "The description must have at least 3 characters."elseall_tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt').map(&:strip) : []@message = "That task already exists." if all_tasks.include?(@description)endendend -
Refactor the
post '/tasks'
route to use the validator: The route now becomes much cleaner. Its responsibility is reduced to orchestrating the process: creating the validator, checking if the data is valid, and acting accordingly.app.rb # require_relative 'task_validator' # If you put it in a separate filepost '/tasks' dovalidator = TaskValidator.new(params['description'])if validator.valid?File.open('tasks.txt', 'a+') { |file| file.puts(params['description'].strip) }session[:message] = "Task '#{params['description'].strip}' created successfully."redirect '/'else@error = validator.message@task_description = params['description']erb :newendend
This approach not only cleans up our routes but also makes the validation logic reusable (for example, in the update route) and much easier to test in isolation.
Handling validation for a single POST
action is a good start, but a complete application needs a coherent set of actions for each “thing” it manages. This leads us to the concept of RESTful resources, a convention for structuring all CRUD operations in a predictable way.
Structure and Conventions: RESTful Resources
As an application grows, following established conventions becomes vital for keeping the code organized, predictable, and easy to maintain.
The RESTful (or REST) approach treats the application’s information as resources (in our case, a ‘task’ is a resource). Each resource is identifiable by a unique URL (like /tasks/123
), and you interact with them using standard HTTP verbs (GET
to read, POST
to create, PUT
to update, and DELETE
to remove). In the context of web applications, this has translated into a convention, popularized by Rails, for structuring interactions around “Resources”. A resource represents an entity in our model (a “task”) and groups the standard management actions known as CRUD (Create, Read, Update, Delete) into a predictable set of routes.
The 7 Routes of a Resource
For a resource like a task
, RESTful conventions define the following seven canonical routes. Adopting this structure makes your application more intuitive for both other developers and end-users.
Action Name | HTTP Verb | URL Pattern | Function |
---|---|---|---|
index | GET | /tasks | Display a list of all tasks |
show | GET | /tasks/:id | Display the details of a specific task |
new | GET | /tasks/new | Display a form to create a new task |
create | POST | /tasks | Save the new task |
edit | GET | /tasks/:id/edit | Display a form to edit a task |
update | PUT | /tasks/:id | Update the specific task |
destroy | DELETE | /tasks/:id | Delete a specific task |
Often, an additional route is added to display a confirmation page before deletion, especially in applications that avoid using JavaScript for alert dialogs.
The Verb Problem: Faking PUT and DELETE
There is a significant limitation: standard HTML forms only support the GET
and POST
methods. You cannot specify method="put"
or method="delete"
directly in a <form>
tag.
Sinatra, like other Ruby frameworks, solves this through method overriding. The idea is to send a normal POST
request but include a special hidden parameter that tells Sinatra to treat it as if it were a PUT
or DELETE
request.
Step 1: Enable the Middleware. First, you must tell Sinatra to use the Rack::MethodOverride
middleware. Add this line at the top of your app.rb
file:
require 'sinatra'use Rack::MethodOverride
enable :sessions# ... rest of the code ...
Step 2: Use the Hidden Field. Now, in your forms that need to use PUT
or DELETE
, keep method="post"
but add a hidden field named _method
with the value of the verb you want to fake.
For example, a form to update a task would look like this:
<form action="/tasks/123" method="post"> <input type="hidden" name="_method" value="PUT">
<label for="description">Edit Description:</label> <input type="text" name="description" id="description" value="Current description"> <input type="submit" value="Update Task"></form>
When Sinatra receives this POST
request, Rack::MethodOverride
will detect the _method
parameter and route it to the route defined with put '/tasks/:id' do ... end
.
With the knowledge of routes, views, forms, sessions, validations, and the structure of RESTful resources, you now have all the tools needed to build a complete CRUD application. The next step is to put it all together in a practical exercise.
Debugging Tools
When something doesn’t work as you expect, Sinatra and your browser provide two useful tools for diagnosing problems.
The Terminal: The Sinatra Log
Every time your application receives a request, the server (Puma, in our case) prints a log line in the terminal where you ran it. This log is your first line of defense and contains vital information:
- Verb and Route: It shows you the actual HTTP method that reached the server and the requested path.
- Status Code: It indicates the result (e.g.,
200
for success,303
for redirection,404
for not found,500
for internal error). - Ruby Errors: If your code throws an exception, you will see the full
backtrace
here, pointing you to the exact file and line of the error.
An example log might look like this:
127.0.0.1 - - [25/Sep/2025:22:55:00 +0200] "POST /tasks/123 HTTP/1.1" 303 - 0.0015
This log tells us that a POST
request was received at /tasks/123
, that the application responded with a redirect (303
), and that it took 0.0015
seconds to process. If you were expecting a PUT
and you see a POST
, you know where to start looking.
The Browser’s “Network” Tab
Your browser’s developer tools are indispensable. The “Network” tab allows you to inspect every request that leaves the browser for your server.
It is especially crucial for debugging problems with forms and verb faking:
- Payload: Here you can see exactly what data your form is sending. Is the
_method
field with the valuePUT
orDELETE
being included? If it’s not here, Sinatra will never see it. - Headers: Review the request and response headers.
- Response: You can see the exact HTML that the server returned before the browser rendered it.
Mastering these two tools will allow you to understand what is really happening between the client and the server.
Exercise: Finish Your To-Do Application
Throughout this tutorial, we have built the foundations of a task application. We already have a functional system for listing, creating, and validating new tasks. Now it’s your turn to complete the CRUD (Create, Read, Update, and Delete) functionality by implementing the missing routes.
Your goal is to add the views and logic to see the details of a task, edit it, and delete it, following the RESTful conventions we have learned.
Preparation: Refactoring Storage
Our current system of saving tasks in tasks.txt
is too simple, as it does not handle unique IDs. To be able to view, edit, and delete specific tasks, we need to identify them. We are going to change our storage to a tasks.csv
file with two columns: id
and description
.
Add the csv
gem to the Gemfile
:
bundle add csv
You can delete the old tasks.txt
file. We will start with an empty task list, which you can populate again using the form.
Replace the file handling code you had with these new helper
methods. You can put them at the end of your app.rb
file or in a separate file.
require 'csv'DB_FILE = 'tasks.csv'
# Returns an array of hashes, one for each task [{id: "1", description: "..."}]def get_all_tasks return [] unless File.exist?(DB_FILE) tasks = [] CSV.foreach(DB_FILE, headers: true, header_converters: :symbol) do |row| tasks << row.to_h end tasksend
# Saves a new task and returns its new IDdef save_task(description) tasks = get_all_tasks new_id = (tasks.map { |t| t[:id].to_i }.max || 0) + 1 CSV.open(DB_FILE, 'a+', write_headers: !File.exist?(DB_FILE), headers: %w[id description]) do |csv| csv << [new_id, description] end new_idend
# Finds a task by its IDdef find_task_by_id(id) get_all_tasks.find { |task| task[:id] == id.to_s }end
# Updates the description of an existing taskdef update_task(id, description) tasks = get_all_tasks tasks.each { |task| task[:description] = description if task[:id] == id.to_s } CSV.open(DB_FILE, 'w', write_headers: true, headers: %w[id description]) do |csv| tasks.each { |task| csv << task.values } endend
# Deletes a task by its IDdef delete_task(id) tasks = get_all_tasks tasks.reject! { |task| task[:id] == id.to_s } CSV.open(DB_FILE, 'w', write_headers: true, headers: %w[id description]) do |csv| tasks.each { |task| csv << task.values } endend
Show me the steps.
Adapt Task Creation (Create)
The first thing is to make sure we can add tasks to our new
tasks.csv
file and that the validation works with the new system.- Adapt the
POST /tasks
route-
Modify the route that handles task creation. To ensure you save the clean description (without extra spaces), first make sure your
TaskValidator
class exposes the sanitized description (for example, by addingattr_reader :description
to the class). -
Then, in the route, within the block where the validation is successful, replace the
File.open(...)
line with a call to thesave_task
helper, passing it the clean description from the validator:save_task(validator.description)
-
- From now on, use
validator.description
wherever you need the description within this route (such as in the session success message), instead of accessingparams['description']
again.
-
Adapt the
TaskValidator
class: The validation logic must also use the new storage system. Make sure that the uniqueness validation (to avoid duplicate tasks) uses theget_all_tasks
helper to read from the CSV. For example:all_tasks = get_all_tasks.map { |task| task[:description] }
- Adapt the
Implement Read Views (Index and Show)
Now that you can add tasks, let’s display them.
-
Adapt the
GET /
(index) route: Modify your main route to use the newget_all_tasks
helper. The line@tasks = get_all_tasks
will suffice. -
Modify the
index.erb
view: Change the list so that each task is a link to its own detail page.views/index.erb <ul><% @tasks.each do |task| %><li><a href="/tasks/<%= task[:id] %>"><%= task[:description] %></a></li><% end %></ul> -
Adapt the
GET /tasks/:id
route and its viewThey now need to read data from the CSV.
- In the route, use the
find_task_by_id
helper to find the task and save it in an instance variable (e.g.,@task
). Make sure it still renders theshow.erb
view. - Adapt the
show.erb
view: The existing view probably displays a simple variable. Modify it to show the task’s description from the hash (e.g.,<p><%= @task[:description] %></p>
) and add a link to go back to the main list (/
).
- In the route, use the
-
Implement Task Editing (Edit and Update)
Now let’s allow users to modify an existing task.
-
Add an “Edit” link in the
show.erb
view that points to/tasks/:id/edit
. -
Create the
GET /tasks/:id/edit
route: This route should find the task and render anedit.erb
view containing a form pre-filled with the current description. -
Create the
edit.erb
view:- You can use the content of
new.erb
and adapt it. - The form should
POST
to the URL/tasks/:id
. - Don’t forget to include the hidden field to fake the
PUT
method. - Add a link to go back to the task page (
show.erb
).
- You can use the content of
-
Create the
PUT /tasks/:id
route:-
Validation Challenge: When updating, the
TaskValidator
needs to be smarter. The uniqueness validation checks if the description already exists and will fail if the user doesn’t change the name. To fix this, you’ll need to pass the current task’s ID to the validator so it can be ignored in the check. -
Adapt
TaskValidator
: Modify the initializer to:- Accept an optional ID:
initialize(description, id: nil)
- Adjust the
valid?
method so that if the ID is present (don’t forget to save the id in an instance variable), it excludes that task from the list before checking for duplicates.# In TaskValidatordef valid?# ... presence and length validations ...# Adapted uniqueness validationother_tasks = get_all_tasks.reject { |task| task[:id] == @id.to_s }if other_tasks.any? { |task| task[:description] == @description }@message = "That task already exists."return falseendtrueend
- Accept an optional ID:
-
Implement the route: Use the adapted
TaskValidator
, passing both the new description and the task’s ID:validator = TaskValidator.new(params['description'], id: params[:id]) -
If it’s valid, use the
update_task(id, description)
helper to save the changes, set a success message in the session, and redirect to the task’s detail page (/tasks/:id
). -
If it’s not valid, re-render the
edit.erb
view with the error message.
-
-
Implement Task Deletion (Destroy)
Finally, we will give the user the ability to delete tasks.
-
Add a “Delete” link in the
show.erb
view. This link should lead to the confirmation page.<a href="/tasks/<%= @task[:id] %>/delete">Delete Task</a> -
Create the confirmation route
GET /tasks/:id/delete
:- This route should find the task by its ID.
- It will render a new view,
delete.erb
, passing it the found task.
-
Create the confirmation view
delete.erb
:-
This page will display a warning message to the user.
-
It will contain the form that actually performs the deletion. This form will
POST
to/tasks/:id
and fake theDELETE
method.views/delete.erb <h2>Confirm Deletion</h2><p>Are you sure you want to delete the task: "<%= @task[:description] %>"?</p><form action="/tasks/<%= @task[:id] %>" method="post"><input type="hidden" name="_method" value="DELETE"><input type="submit" value="Yes, Delete Task"><a href="/tasks/<%= @task[:id] %>">Cancel</a></form>
-
-
Create the
DELETE /tasks/:id
route:- This is the final route that handles the deletion logic.
- It calls the
delete_task
helper to remove the task from the file. - It sets a success message in the session (e.g., “Task deleted successfully”).
- It redirects the user to the main list (
/
).
-
Conclusion
Congratulations! If you’ve made it this far and completed the exercise, you haven’t just built a to-do application, you’ve mastered the fundamental concepts that make Sinatra such a powerful and flexible tool.
Throughout this tutorial, you have learned how to:
-
Structure an application with routes: You now understand how to map HTTP verbs and URL patterns to code blocks to handle requests.
-
Manage data with parameters: You know how to capture information from the URL and forms through the
params
hash. -
Separate logic from presentation with ERB views: You’ve learned to generate dynamic HTML using templates, passing data from your routes.
-
Create a consistent user experience with layouts: You know how to reuse a common page structure throughout your application.
-
Implement user flows with the PRG pattern: You understand why it’s crucial to redirect after a
POST
,PUT
, orDELETE
request to prevent form resubmission. -
Maintain state between requests with sessions: You’ve used sessions to implement flash messages, providing feedback to the user after each action.
-
Ensure data integrity with validations: You know how to validate user input on the server, manage success and error cases, and refactor this logic into separate classes.
-
Structure your application with RESTful conventions: You understand the concept of a “resource” and how to organize CRUD operations into a standard set of routes.
With these tools, you now have a solid foundation not only to create your own applications with Sinatra, but also to better understand how more complex web frameworks work. Keep experimenting and building!