Sinatra Tutorial: Building a To-Do Application

David Morales David Morales
/
Digital illustration of a classic white fedora hat with a black band, centered on a soft, uniform reddish background.

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:

As a practical project, we will develop an application to manage a to-do list stored in a local file. You will learn to:

  1. Display and add new tasks using forms.
  2. Validate user input on the server.
  3. Manage state between requests using sessions.
  4. 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}"]]
end
end

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 405
end

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):

Terminal window
mkdir todo-list
cd todo-list
bundle init
bundle 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:

app.rb
require 'sinatra'
get '/' do
'My To-Do List'
end

Run the file from your terminal:

Terminal window
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 Puma
Puma 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:

app.rb
# Displays the main list of tasks
get '/' do
'All tasks will be displayed here.'
end
# Displays the form to create a new task
get '/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.

app.rb
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:

app.rb
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.

app.rb
get '/tasks/:id' do
erb :show, locals: params
end

By convention, Sinatra looks for template files in a directory named views at the root of your project. Let’s create this structure:

  1. Create a directory named views.
  2. Inside views, create a file named show.erb.
  3. Add the following content to access id as if it were a local variable:
views/show.erb
<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.

app.rb
get '/tasks/:id' do
@id = params[:id]
erb :show
end

In the template, it is accessed with the same syntax:

views/show.erb
<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:

views/layout.erb
<!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:

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:

Add a GET route in app.rb to display this form:

app.rb
get '/tasks/new' do
erb :new
end

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.

app.rb
post '/tasks' do
task_description = params['description']
File.open('tasks.txt', 'a+') do |file|
file.puts(task_description)
end
end

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:

  1. POST: The client sends data to create or update a resource.
  2. Redirect: The server, after processing the request, responds with a redirection (status code 303 See Other) to a new URL.
  3. 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:

app.rb
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.

app.rb
get '/' do
@tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt') : []
erb :index
end

Create the views/index.erb view. This view will be responsible for iterating over the @tasks variable and displaying each one in a list.

views/index.erb
<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:

app.rb
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.

app.rb
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.

app.rb
get '/' do
@message = session.delete(:message)
@tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt') : []
erb :index
end

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.

views/layout.erb
<!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.

  1. The user submits data to a POST route.
  2. The route validates the received data.
  3. 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).
  4. 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:

app.rb
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 '/'
end
end

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.

views/new.erb
<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.

  1. Create the TaskValidator class: You can add it at the end of your app.rb or, ideally, in its own file (e.g., task_validator.rb) and require it.

    task_validator.rb
    class TaskValidator
    attr_reader :message
    def initialize(description)
    @description = description.to_s.strip
    @message = nil
    end
    def valid?
    validate
    @message.nil?
    end
    private
    def validate
    if @description.empty?
    @message = "The task description cannot be empty."
    elsif @description.length < 3
    @message = "The description must have at least 3 characters."
    else
    all_tasks = File.exist?('tasks.txt') ? File.readlines('tasks.txt').map(&:strip) : []
    @message = "That task already exists." if all_tasks.include?(@description)
    end
    end
    end
  2. 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 file
    post '/tasks' do
    validator = 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 :new
    end
    end

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 NameHTTP VerbURL PatternFunction
indexGET/tasksDisplay a list of all tasks
showGET/tasks/:idDisplay the details of a specific task
newGET/tasks/newDisplay a form to create a new task
createPOST/tasksSave the new task
editGET/tasks/:id/editDisplay a form to edit a task
updatePUT/tasks/:idUpdate the specific task
destroyDELETE/tasks/:idDelete 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:

app.rb
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:

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:

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:

Terminal window
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.

storage.rb
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
tasks
end
# Saves a new task and returns its new ID
def 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_id
end
# Finds a task by its ID
def find_task_by_id(id)
get_all_tasks.find { |task| task[:id] == id.to_s }
end
# Updates the description of an existing task
def 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 }
end
end
# Deletes a task by its ID
def 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 }
end
end
Show me the steps.
  1. 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.

    1. 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 adding attr_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 the save_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 accessing params['description'] again.
    1. 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 the get_all_tasks helper to read from the CSV. For example:

      all_tasks = get_all_tasks.map { |task| task[:description] }
  2. Implement Read Views (Index and Show)

    Now that you can add tasks, let’s display them.

    1. Adapt the GET / (index) route: Modify your main route to use the new get_all_tasks helper. The line @tasks = get_all_tasks will suffice.

    2. 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>
    3. Adapt the GET /tasks/:id route and its view

      They 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 the show.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 (/).
  3. Implement Task Editing (Edit and Update)

    Now let’s allow users to modify an existing task.

    1. Add an “Edit” link in the show.erb view that points to /tasks/:id/edit.

    2. Create the GET /tasks/:id/edit route: This route should find the task and render an edit.erb view containing a form pre-filled with the current description.

    3. 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).
    4. Create the PUT /tasks/:id route:

      1. 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.

      2. 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 TaskValidator
          def valid?
          # ... presence and length validations ...
          # Adapted uniqueness validation
          other_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 false
          end
          true
          end
      3. 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])
      4. 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).

      5. If it’s not valid, re-render the edit.erb view with the error message.

  4. Implement Task Deletion (Destroy)

    Finally, we will give the user the ability to delete tasks.

    1. 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>
    2. 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.
    3. 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 the DELETE 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>
    4. 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:

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!